diff --git a/webrtc-jni/src/main/cpp/src/media/audio/VoiceActivityListener.cpp b/webrtc-jni/src/main/cpp/src/media/audio/VoiceActivityListener.cpp index 2480084ad..11bf13fbf 100644 --- a/webrtc-jni/src/main/cpp/src/media/audio/VoiceActivityListener.cpp +++ b/webrtc-jni/src/main/cpp/src/media/audio/VoiceActivityListener.cpp @@ -27,7 +27,7 @@ namespace jni listener(listener), javaClass(JavaClasses::get(env)), threshold(threshold), - hangoverChunks((hangoverMs + 9) / 10), + hangoverChunks(hangoverMs / 10 + (hangoverMs % 10 != 0)), quietChunks(0), speaking(false) { diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionFactory.java b/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionFactory.java index 7869b7026..7a46158c0 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionFactory.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionFactory.java @@ -446,8 +446,12 @@ public void addVoiceActivityListener(VoiceActivityConfig config, VoiceActivityLi return; } + // Preserve partial milliseconds before native code rounds to 10 ms chunks. + int hangoverMs = (int) config.getHangover().toMillis() + + (config.getHangover().getNano() % 1_000_000 == 0 ? 0 : 1); + long nativeListener = addVoiceActivityListenerInternal(listener, (float) config.getThreshold(), - (int) config.getHangover().toMillis()); + hangoverMs); voiceActivityListeners.put(listener, nativeListener); } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioTrack.java b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioTrack.java index 7859915ae..47b8f1428 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioTrack.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/AudioTrack.java @@ -121,8 +121,12 @@ public void addVoiceActivityListener(VoiceActivityConfig config, VoiceActivityLi return; } + // Preserve partial milliseconds before native code rounds to 10 ms chunks. + int hangoverMs = (int) config.getHangover().toMillis() + + (config.getHangover().getNano() % 1_000_000 == 0 ? 0 : 1); + final long nativeSink = addVoiceActivityListenerInternal(listener, (float) config.getThreshold(), - (int) config.getHangover().toMillis()); + hangoverMs); voiceActivityListeners.put(listener, nativeSink); } diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/VoiceActivityListenerTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/VoiceActivityListenerTest.java index 03b65370c..678f4d66f 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/VoiceActivityListenerTest.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/VoiceActivityListenerTest.java @@ -26,6 +26,8 @@ import java.util.Arrays; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import dev.onvoid.webrtc.PeerConnectionFactory; import dev.onvoid.webrtc.TestBase; @@ -112,6 +114,65 @@ void hangoverHoldsSpeech() { + " chunks"); } + @Test + void trackHangoverBoundaries() { + assertHangoverBoundaries(track::addVoiceActivityListener, pcm -> push(pcm, 16000, 1)); + } + + @Test + void factoryHangoverBoundaries() { + AudioProcessing processing = new AudioProcessing(); + HeadlessAudioDeviceModule module = new HeadlessAudioDeviceModule(); + PeerConnectionFactory processed = new PeerConnectionFactory(module, processing); + + try { + assertHangoverBoundaries(processed::addVoiceActivityListener, + pcm -> process(processing, pcm, 16000)); + } + finally { + processed.dispose(); + module.dispose(); + processing.dispose(); + } + } + + private void assertHangoverBoundaries( + BiConsumer addListener, + Consumer deliver) { + Duration[] hangovers = { Duration.ZERO, Duration.ofNanos(1), Duration.ofMillis(10), + Duration.ofMillis(10).plusNanos(1), Duration.ofNanos(10500000), Duration.ofMillis(20) }; + int[] chunk = { 0 }; + int[] lastEnd = new int[hangovers.length]; + Arrays.fill(lastEnd, -1); + + for (int i = 0; i < hangovers.length; i++) { + final int index = i; + addListener.accept(new VoiceActivityConfig(0.5, hangovers[i]), speaking -> { + if (!speaking) { + lastEnd[index] = chunk[0]; + } + }); + } + + List maximumChanges = new CopyOnWriteArrayList<>(); + addListener.accept(new VoiceActivityConfig(0.5, Duration.ofMillis(Integer.MAX_VALUE)), + maximumChanges::add); + + byte[] pcm = SpeechFixture.concat(speech(16000, 1), silence(2000, 16000, 1)); + for (int offset = 0; offset + 320 <= pcm.length; offset += 320) { + deliver.accept(Arrays.copyOfRange(pcm, offset, offset + 320)); + chunk[0]++; + } + + assertTrue(lastEnd[0] >= 0, "Speech must end for the rounding comparisons"); + assertEquals(lastEnd[0] + 1, lastEnd[1], "1 ns rounds up to 10 ms"); + assertEquals(lastEnd[1], lastEnd[2], "Exactly 10 ms stays one chunk"); + assertEquals(lastEnd[2] + 1, lastEnd[3], "10 ms + 1 ns rounds up to 20 ms"); + assertEquals(lastEnd[3], lastEnd[4], "10.5 ms rounds up to 20 ms"); + assertEquals(lastEnd[4], lastEnd[5], "Exactly 20 ms stays two chunks"); + assertEquals(List.of(true), maximumChanges, "The maximum hangover must keep speech active"); + } + @Test void shortHangoverSplitsSpeech() { // Without a hangover, the pauses between words end speech. @@ -278,6 +339,8 @@ void configRejectsOutOfRange() { assertThrows(IllegalArgumentException.class, () -> new VoiceActivityConfig(0.5, Duration.ofMillis(-10))); assertThrows(IllegalArgumentException.class, () -> new VoiceActivityConfig(0.5, Duration.ofDays(30))); + assertThrows(IllegalArgumentException.class, () -> new VoiceActivityConfig(0.5, + Duration.ofMillis(Integer.MAX_VALUE).plusNanos(1))); assertThrows(NullPointerException.class, () -> new VoiceActivityConfig(0.5, null)); }