diff --git a/webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt b/webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt index c9c24d056..d48c874d8 100644 --- a/webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt +++ b/webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt @@ -450,7 +450,7 @@ if (PATCHES) if (RESULT EQUAL 0) message(STATUS "Patch applied: ${PATCH}") - elseif(PATCH MATCHES "/patches/common/") + elseif(PATCH MATCHES "/patches/common/" OR PATCH MATCHES "/pulseaudio_reinitialization[.]patch$") message(FATAL_ERROR "Required WebRTC patch could not be applied: ${PATCH}") else() message(STATUS "Warning: Patch ${PATCH} not applied ....") diff --git a/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/linux/.gitattributes b/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/linux/.gitattributes new file mode 100644 index 000000000..9812ceb1f --- /dev/null +++ b/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/linux/.gitattributes @@ -0,0 +1 @@ +*.patch text eol=lf diff --git a/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/linux/pulseaudio_reinitialization.patch b/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/linux/pulseaudio_reinitialization.patch new file mode 100644 index 000000000..8d7faeb8b --- /dev/null +++ b/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/linux/pulseaudio_reinitialization.patch @@ -0,0 +1,14 @@ +--- a/modules/audio_device/linux/audio_device_pulse_linux.cc ++++ b/modules/audio_device/linux/audio_device_pulse_linux.cc +@@ -178,3 +178,11 @@ AudioDeviceGeneric::InitStatus AudioDeviceLinuxPulse::Init() { ++ // Terminate() stops both processing threads with this flag. The ADM is ++ // reused when a factory creates another peer after closing its last one. ++ // Reset it before spawning either thread so audio can start again. ++ { ++ MutexLock lock(&mutex_); ++ quit_ = false; ++ } ++ + // RECORDING + const auto attributes = + ThreadAttributes().SetPriority(ThreadPriority::kRealtime); diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/PulseAudioSignalingIntegrationTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/PulseAudioSignalingIntegrationTest.java new file mode 100644 index 000000000..a3c250503 --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/PulseAudioSignalingIntegrationTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import dev.onvoid.webrtc.media.MediaType; +import dev.onvoid.webrtc.media.audio.AudioDeviceModule; +import dev.onvoid.webrtc.media.audio.AudioLayer; +import dev.onvoid.webrtc.media.audio.AudioOptions; +import dev.onvoid.webrtc.media.audio.AudioTrack; +import dev.onvoid.webrtc.media.audio.AudioTrackSource; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +/** See PulseAudioIntegrationTest for the optional PulseAudio server setup. */ +@EnabledOnOs(OS.LINUX) +@EnabledIfSystemProperty(named = "webrtc.test.pulseaudio", matches = "true") +@Execution(ExecutionMode.SAME_THREAD) +class PulseAudioSignalingIntegrationTest extends TestBase { + + @Test + void remoteAnswerRestartsPlayout() throws Exception { + AudioDeviceModule adm = new AudioDeviceModule(AudioLayer.kLinuxPulseAudio); + try { + PeerConnectionFactory receiverFactory = new PeerConnectionFactory(adm); + try { + AudioTrackSource source = factory.createAudioSource(new AudioOptions()); + try { + AudioTrack track = factory.createAudioTrack("audio", source); + try { + for (int cycle = 1; cycle <= 5; cycle++) { + negotiate(receiverFactory, track, cycle); + } + } + finally { + track.dispose(); + } + } + finally { + source.dispose(); + } + } + finally { + receiverFactory.dispose(); + } + } + finally { + adm.dispose(); + } + } + + private void negotiate(PeerConnectionFactory receiverFactory, AudioTrack track, + int cycle) throws Exception { + PeerConnectionObserver observer = new PeerConnectionObserver() { + @Override + public void onIceCandidate(RTCIceCandidate candidate) { } + }; + RTCPeerConnection receiver = receiverFactory.createPeerConnection( + new RTCConfiguration(), observer); + try { + RTCPeerConnection sender = factory.createPeerConnection(new RTCConfiguration(), observer); + try { + RTCRtpTransceiverInit init = new RTCRtpTransceiverInit(); + init.direction = RTCRtpTransceiverDirection.RECV_ONLY; + receiver.addTransceiver(MediaType.AUDIO, init).dispose(); + sender.addTrack(track, Collections.singletonList("stream")).dispose(); + + TestCreateDescObserver offerObserver = new TestCreateDescObserver(); + receiver.createOffer(new RTCOfferOptions(), offerObserver); + RTCSessionDescription offer = offerObserver.get(15, TimeUnit.SECONDS); + TestSetDescObserver localOffer = new TestSetDescObserver(); + receiver.setLocalDescription(offer, localOffer); + localOffer.get(15, TimeUnit.SECONDS); + TestSetDescObserver remoteOffer = new TestSetDescObserver(); + sender.setRemoteDescription(offer, remoteOffer); + remoteOffer.get(15, TimeUnit.SECONDS); + + TestCreateDescObserver answerObserver = new TestCreateDescObserver(); + sender.createAnswer(new RTCAnswerOptions(), answerObserver); + RTCSessionDescription answer = answerObserver.get(15, TimeUnit.SECONDS); + TestSetDescObserver localAnswer = new TestSetDescObserver(); + sender.setLocalDescription(answer, localAnswer); + localAnswer.get(15, TimeUnit.SECONDS); + + // Applying the answer starts PulseAudio even without ICE or RTP. + // A dead processing thread causes a synchronous ten-second wait. + TestSetDescObserver remoteAnswer = new TestSetDescObserver(); + long started = System.nanoTime(); + receiver.setRemoteDescription(answer, remoteAnswer); + remoteAnswer.get(15, TimeUnit.SECONDS); + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + System.out.printf("PulseAudio remote answer cycle %d: %d ms%n", cycle, elapsed); + assertTrue(elapsed < 5000, + "Audio startup timed out in cycle " + cycle + ": " + elapsed + " ms"); + } + finally { + sender.close(); + } + } + finally { + receiver.close(); + } + } +} diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/PulseAudioIntegrationTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/PulseAudioIntegrationTest.java new file mode 100644 index 000000000..af663000c --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/PulseAudioIntegrationTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.audio; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.PeerConnectionObserver; +import dev.onvoid.webrtc.RTCConfiguration; +import dev.onvoid.webrtc.RTCIceCandidate; +import dev.onvoid.webrtc.RTCPeerConnection; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +/** + * Requires a running PulseAudio-compatible server. Run explicitly with + * -Dwebrtc.test.pulseaudio=true; PULSE_SINK and PULSE_SOURCE can select a + * virtual sink and its monitor so no speakers or microphone are used. + */ +@EnabledOnOs(OS.LINUX) +@EnabledIfSystemProperty(named = "webrtc.test.pulseaudio", matches = "true") +@Execution(ExecutionMode.SAME_THREAD) +class PulseAudioIntegrationTest { + + @Test + void playoutRestarts() throws Exception { + restartAudio(false); + } + + @Test + void recordingRestarts() throws Exception { + restartAudio(true); + } + + private void restartAudio(boolean recording) throws Exception { + AudioDeviceModule adm = new AudioDeviceModule(AudioLayer.kLinuxPulseAudio); + try { + PeerConnectionFactory factory = new PeerConnectionFactory(adm); + try { + // Closing the last peer terminates the ADM. Each subsequent + // peer must reinitialize both PulseAudio processing threads. + for (int cycle = 1; cycle <= 5; cycle++) { + RTCPeerConnection peer = factory.createPeerConnection( + new RTCConfiguration(), new PeerConnectionObserver() { + @Override + public void onIceCandidate(RTCIceCandidate candidate) { } + }); + try { + System.out.printf("PulseAudio %s cycle %d%n", + recording ? "recording" : "playout", cycle); + CountDownLatch frames = new CountDownLatch(3); + if (recording) { + adm.setAudioSink((data, samples, bytes, channels, rate, delay, drift) -> { + if (samples > 0) { + frames.countDown(); + } + }); + adm.initRecording(); + adm.startRecording(); + } + else { + adm.setAudioSource((data, samples, bytes, channels, rate) -> { + frames.countDown(); + return samples; + }); + adm.initPlayout(); + adm.startPlayout(); + } + assertTrue(frames.await(5, TimeUnit.SECONDS), + "No audio callbacks in cycle " + cycle); + } + finally { + peer.close(); + } + } + } + finally { + factory.dispose(); + } + } + finally { + adm.dispose(); + } + } +}