diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 83692d53d..c7250475c 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -38,8 +38,8 @@ runs: uses: actions/cache/restore@v6 with: path: ~/${{ env.WEBRTC_INSTALL_FOLDER }} - key: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt') }}-${{ hashFiles('webrtc-jni/pom.xml') }} - restore-keys: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt') }}- + key: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt', 'webrtc-jni/src/main/cpp/dependencies/webrtc/patches/**/*.patch') }}-${{ hashFiles('webrtc-jni/pom.xml') }} + restore-keys: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt', 'webrtc-jni/src/main/cpp/dependencies/webrtc/patches/**/*.patch') }}- - name: Derive the FFmpeg version from the pom run: echo "FFMPEG_VERSION=$(sed -n 's|.*\([^<]*\).*|\1|p' webrtc-java-media/pom.xml)" >> "$GITHUB_ENV" diff --git a/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/windows/wasapi_mix_format.patch b/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/windows/wasapi_mix_format.patch new file mode 100644 index 000000000..0f8167d68 --- /dev/null +++ b/webrtc-jni/src/main/cpp/dependencies/webrtc/patches/windows/wasapi_mix_format.patch @@ -0,0 +1,472 @@ +--- a/modules/audio_device/BUILD.gn ++++ b/modules/audio_device/BUILD.gn +@@ -261,9 +261,11 @@ + "../../api:scoped_refptr", + "../../api:sequence_checker", + "../../api/audio:audio_device", ++ "../../api/audio:audio_frame_api", + "../../api/environment", + "../../api/task_queue", + "../../api/units:time_delta", ++ "../../audio/utility:audio_frame_operations", + "../../common_audio", + "../../common_audio:common_audio_c", + "../../rtc_base:buffer", +--- a/modules/audio_device/win/audio_device_core_win.cc ++++ b/modules/audio_device/win/audio_device_core_win.cc +@@ -25,9 +25,14 @@ + #include + #include + ++#include ++#include ++#include + #include + ++#include "api/audio/channel_layout.h" + #include "api/make_ref_counted.h" ++#include "audio/utility/channel_mixing_matrix.h" + #include "rtc_base/checks.h" + #include "rtc_base/logging.h" + #include "rtc_base/platform_thread.h" +@@ -77,6 +82,231 @@ + + enum { kAecCaptureStreamIndex = 0, kAecRenderStreamIndex = 1 }; + ++// Fractional bits of the fixed-point coefficients of a channel mix. Firefox's ++// cubeb mixer takes 15, which its 32-bit sums require. Sums here are 64-bit, ++// so they can take 24: a float coefficient from 1/2 to 1 fits them exactly, ++// a smaller one to within 2^-25, and a row of WebRTC's 7.1 downmix, which ++// sums to about 6, keeps the sum of full-scale input below 2^42. ++constexpr int kCoefficientBits = 24; ++ ++// Returns the channel layout of a WASAPI format, mapping the speaker masks ++// the way Chrome's ChannelConfigToChannelLayout() does. A mask that is not ++// one of these, or that does not match the channel count, is treated as ++// discrete channels, which are copied by index. ++ChannelLayout WaveFormatToChannelLayout(const WAVEFORMATEX* format) { ++ ChannelLayout layout = GuessChannelLayout(format->nChannels); ++ if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE && format->cbSize >= 22) { ++ switch ( ++ reinterpret_cast(format)->dwChannelMask) { ++ case KSAUDIO_SPEAKER_MONO: ++ layout = CHANNEL_LAYOUT_MONO; ++ break; ++ case KSAUDIO_SPEAKER_STEREO: ++ layout = CHANNEL_LAYOUT_STEREO; ++ break; ++ case KSAUDIO_SPEAKER_QUAD: ++ layout = CHANNEL_LAYOUT_QUAD; ++ break; ++ case KSAUDIO_SPEAKER_SURROUND: ++ layout = CHANNEL_LAYOUT_4_0; ++ break; ++ case KSAUDIO_SPEAKER_5POINT1: ++ layout = CHANNEL_LAYOUT_5_1_BACK; ++ break; ++ case KSAUDIO_SPEAKER_5POINT1_SURROUND: ++ layout = CHANNEL_LAYOUT_5_1; ++ break; ++ case KSAUDIO_SPEAKER_7POINT1: ++ layout = CHANNEL_LAYOUT_7_1_WIDE; ++ break; ++ case KSAUDIO_SPEAKER_7POINT1_SURROUND: ++ layout = CHANNEL_LAYOUT_7_1; ++ break; ++ default: ++ break; ++ } ++ } ++ if (layout == CHANNEL_LAYOUT_UNSUPPORTED || ++ ChannelLayoutToChannelCount(layout) != format->nChannels) { ++ layout = CHANNEL_LAYOUT_DISCRETE; ++ } ++ return layout; ++} ++ ++// Builds the stream format for a device that accepts none of the formats ++// WebRTC asks for: 16-bit PCM in the channel count, speaker mask and sample ++// rate of the device's shared-mode mix format. The audio engine converts ++// 16-bit PCM to its float mix format itself, but not the channel count or ++// the rate, so these must match the mix format. Chrome and Firefox open such ++// devices the same way and convert the channels themselves. `conversion` gets ++// the conversion from `client_channels` to the device (render) or back ++// (capture), and stays empty when the channel counts match. Returns false if ++// the mix format cannot be used. ++bool MixFormatForClient(const WAVEFORMATEX* mix_format, ++ uint16_t client_channels, ++ bool render, ++ WAVEFORMATEXTENSIBLE* format, ++ CoreAudioChannelConversion* conversion) { ++ *conversion = CoreAudioChannelConversion(); ++ // WebRTC exchanges audio in 10 ms blocks of whole frames. ++ if (mix_format->nChannels == 0 || mix_format->nSamplesPerSec % 100 != 0) { ++ return false; ++ } ++ ++ *format = WAVEFORMATEXTENSIBLE(); ++ format->Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; ++ format->Format.nChannels = mix_format->nChannels; ++ format->Format.nSamplesPerSec = mix_format->nSamplesPerSec; ++ format->Format.wBitsPerSample = 16; ++ format->Format.nBlockAlign = ++ format->Format.nChannels * format->Format.wBitsPerSample / 8; ++ format->Format.nAvgBytesPerSec = ++ format->Format.nSamplesPerSec * format->Format.nBlockAlign; ++ format->Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); ++ format->Samples.wValidBitsPerSample = format->Format.wBitsPerSample; ++ format->SubFormat = KSDATAFORMAT_SUBTYPE_PCM; ++ if (mix_format->wFormatTag == WAVE_FORMAT_EXTENSIBLE && ++ mix_format->cbSize >= 22) { ++ format->dwChannelMask = ++ reinterpret_cast(mix_format) ++ ->dwChannelMask; ++ } ++ ++ if (format->Format.nChannels == client_channels) { ++ return true; ++ } ++ ++ const ChannelLayout client_layout = GuessChannelLayout(client_channels); ++ const ChannelLayout device_layout = ++ WaveFormatToChannelLayout(&format->Format); ++ std::vector> matrix; ++ const bool remapping = ++ render ? ChannelMixingMatrix(client_layout, client_channels, ++ device_layout, format->Format.nChannels) ++ .CreateTransformationMatrix(&matrix) ++ : ChannelMixingMatrix(device_layout, format->Format.nChannels, ++ client_layout, client_channels) ++ .CreateTransformationMatrix(&matrix); ++ conversion->input_channels = matrix[0].size(); ++ if (remapping) { ++ // Each output channel takes one input channel as it is, or none. ++ conversion->channel_map.assign(matrix.size(), -1); ++ for (size_t out = 0; out < matrix.size(); ++out) { ++ for (size_t in = 0; in < matrix[out].size(); ++in) { ++ if (matrix[out][in] > 0) { ++ conversion->channel_map[out] = static_cast(in); ++ break; ++ } ++ } ++ } ++ return true; ++ } ++ conversion->terms.resize(matrix.size()); ++ for (size_t out = 0; out < matrix.size(); ++out) { ++ int64_t sum = 0; ++ for (size_t in = 0; in < matrix[out].size(); ++in) { ++ const int32_t coefficient = static_cast( ++ std::lrintf(matrix[out][in] * (1 << kCoefficientBits))); ++ if (coefficient != 0) { ++ conversion->terms[out].emplace_back(in, coefficient); ++ sum += std::abs(coefficient); ++ } ++ } ++ // Full-scale input on every term would leave the 16-bit range. ++ if (sum > (int64_t{1} << kCoefficientBits)) { ++ conversion->clipping = true; ++ } ++ } ++ return true; ++} ++ ++// Rounds a sum of terms to a 16-bit sample, as Firefox's cubeb mixer does, ++// and clamps it when `kClamp`. ++template ++int16_t RoundSum(int64_t sum) { ++ int64_t sample = ++ (sum + (int64_t{1} << (kCoefficientBits - 1))) >> kCoefficientBits; ++ if (kClamp) { ++ sample = std::clamp(sample, -32768, 32767); ++ } ++ return static_cast(sample); ++} ++ ++// Mixes `frames` interleaved frames with the terms of `conversion`, one output ++// channel at a time and with loops for the usual numbers of terms, as ++// Firefox's cubeb mixer does. ++template ++void MixTerms(const CoreAudioChannelConversion& conversion, ++ const int16_t* input, ++ int16_t* output, ++ size_t frames) { ++ const size_t input_channels = conversion.input_channels; ++ const size_t output_channels = conversion.terms.size(); ++ for (size_t out = 0; out < output_channels; ++out) { ++ const auto& terms = conversion.terms[out]; ++ int16_t* dest = output + out; ++ switch (terms.size()) { ++ case 0: ++ for (size_t frame = 0; frame < frames; ++frame) { ++ dest[frame * output_channels] = 0; ++ } ++ break; ++ case 1: { ++ const int16_t* a = input + terms[0].first; ++ const int64_t ca = terms[0].second; ++ for (size_t frame = 0; frame < frames; ++frame) { ++ dest[frame * output_channels] = ++ RoundSum(ca * a[frame * input_channels]); ++ } ++ break; ++ } ++ case 2: { ++ const int16_t* a = input + terms[0].first; ++ const int16_t* b = input + terms[1].first; ++ const int64_t ca = terms[0].second; ++ const int64_t cb = terms[1].second; ++ for (size_t frame = 0; frame < frames; ++frame) { ++ dest[frame * output_channels] = RoundSum( ++ ca * a[frame * input_channels] + cb * b[frame * input_channels]); ++ } ++ break; ++ } ++ default: ++ for (size_t frame = 0; frame < frames; ++frame) { ++ const int16_t* in = input + frame * input_channels; ++ int64_t sum = 0; ++ for (const auto& [channel, coefficient] : terms) { ++ sum += int64_t{coefficient} * in[channel]; ++ } ++ dest[frame * output_channels] = RoundSum(sum); ++ } ++ break; ++ } ++ } ++} ++ ++// Converts `frames` interleaved frames with `conversion`: copies them by ++// channel when it only routes channels, and mixes them otherwise. ++void ConvertChannels(const CoreAudioChannelConversion& conversion, ++ const int16_t* input, ++ int16_t* output, ++ size_t frames) { ++ if (!conversion.channel_map.empty()) { ++ const size_t output_channels = conversion.channel_map.size(); ++ for (size_t frame = 0; frame < frames; ++frame) { ++ for (size_t out = 0; out < output_channels; ++out) { ++ const int in = conversion.channel_map[out]; ++ *output++ = in < 0 ? 0 : input[in]; ++ } ++ input += conversion.input_channels; ++ } ++ } else if (conversion.clipping) { ++ MixTerms(conversion, input, output, frames); ++ } else { ++ MixTerms(conversion, input, output, frames); ++ } ++} ++ + // An implementation of IMediaBuffer, as required for + // IMediaObject::ProcessOutput(). After consuming data provided by + // ProcessOutput(), call SetLength() to update the buffer availability. +@@ -1822,6 +2052,8 @@ + HRESULT hr = S_OK; + WAVEFORMATEX* pWfxOut = NULL; + WAVEFORMATEX Wfx = WAVEFORMATEX(); ++ WAVEFORMATEXTENSIBLE WfxMix = WAVEFORMATEXTENSIBLE(); ++ WAVEFORMATEX* pWfxSelected = &Wfx; + WAVEFORMATEX* pWfxClosestMatch = NULL; + UINT bufferFrameCount = 0; + constexpr int freqs[] = {48000, 44100, 16000, 96000, 32000, 8000}; +@@ -1901,6 +2133,7 @@ + // TODO(andrew): what happens in the event of failure in the above loop? + // Is _ptrClientOut->Initialize expected to fail? + // Same in InitRecording(). ++ _playConversion = CoreAudioChannelConversion(); + if (hr == S_OK) { + _playAudioFrameSize = Wfx.nBlockAlign; + // Block size is the number of samples each channel in 10ms. +@@ -1924,6 +2157,29 @@ + RTC_LOG(LS_VERBOSE) << "_playAudioFrameSize: " << _playAudioFrameSize; + RTC_LOG(LS_VERBOSE) << "_playBlockSize : " << _playBlockSize; + RTC_LOG(LS_VERBOSE) << "_playChannels : " << _playChannels; ++ } else if (pWfxOut && ++ MixFormatForClient(pWfxOut, _playChannelsPrioList[0], ++ /*render=*/true, &WfxMix, &_playConversion)) { ++ // None of the formats above is supported. This happens when the mix ++ // format has more than two channels, as with gaming headsets that mix in ++ // 7.1, since a shared-mode stream must have the channel count of the mix ++ // format. Open the stream in the mix format and convert WebRTC's audio to ++ // it in the render thread. ++ pWfxSelected = &WfxMix.Format; ++ _playAudioFrameSize = WfxMix.Format.nBlockAlign; ++ _playBlockSize = WfxMix.Format.nSamplesPerSec / 100; ++ _playSampleRate = WfxMix.Format.nSamplesPerSec; ++ _devicePlaySampleRate = WfxMix.Format.nSamplesPerSec; ++ _devicePlayBlockSize = WfxMix.Format.nSamplesPerSec / 100; ++ _playChannels = _playChannelsPrioList[0]; ++ _playConvertBuffer.resize(_playBlockSize * _playChannels); ++ ++ RTC_LOG(LS_INFO) << "Rendering in the mix format: nChannels=" ++ << WfxMix.Format.nChannels ++ << ", nSamplesPerSec=" << WfxMix.Format.nSamplesPerSec ++ << ", dwChannelMask=0x" ++ << ToHex(static_cast(WfxMix.dwChannelMask)) ++ << ", converted from " << _playChannels << " channel(s)"; + } + + // Create a rendering stream. +@@ -1959,7 +2215,7 @@ + hnsBufferDuration, // requested buffer capacity as a time value (in + // 100-nanosecond units) + 0, // periodicity +- &Wfx, // selected wave format ++ pWfxSelected, // selected wave format + NULL); // session GUID + + if (FAILED(hr)) { +@@ -2056,6 +2312,7 @@ + ptrWav->cbSize = 0; + + // Set the VoE format equal to the AEC output format. ++ _recConversion = CoreAudioChannelConversion(); + _recAudioFrameSize = ptrWav->nBlockAlign; + _recSampleRate = ptrWav->nSamplesPerSec; + _recBlockSize = ptrWav->nSamplesPerSec / 100; +@@ -2136,6 +2393,8 @@ + HRESULT hr = S_OK; + WAVEFORMATEX* pWfxIn = NULL; + WAVEFORMATEXTENSIBLE Wfx = WAVEFORMATEXTENSIBLE(); ++ WAVEFORMATEXTENSIBLE WfxMix = WAVEFORMATEXTENSIBLE(); ++ WAVEFORMATEXTENSIBLE* pWfxSelected = &Wfx; + WAVEFORMATEX* pWfxClosestMatch = NULL; + UINT bufferFrameCount = 0; + constexpr int freqs[6] = {48000, 44100, 16000, 96000, 32000, 8000}; +@@ -2216,6 +2475,7 @@ + break; + } + ++ _recConversion = CoreAudioChannelConversion(); + if (hr == S_OK) { + _recAudioFrameSize = Wfx.Format.nBlockAlign; + _recSampleRate = Wfx.Format.nSamplesPerSec; +@@ -2236,6 +2496,25 @@ + RTC_LOG(LS_VERBOSE) << "_recAudioFrameSize: " << _recAudioFrameSize; + RTC_LOG(LS_VERBOSE) << "_recBlockSize : " << _recBlockSize; + RTC_LOG(LS_VERBOSE) << "_recChannels : " << _recChannels; ++ } else if (pWfxIn && ++ MixFormatForClient(pWfxIn, _recChannelsPrioList[0], ++ /*render=*/false, &WfxMix, &_recConversion)) { ++ // None of the formats above is supported; see InitPlayout(). Open the ++ // stream in the mix format and convert it to WebRTC's channel count in ++ // the capture thread. `_recAudioFrameSize` stays the size of a frame in ++ // WebRTC's format, which is what the capture thread buffers. ++ pWfxSelected = &WfxMix; ++ _recChannels = _recChannelsPrioList[0]; ++ _recAudioFrameSize = _recChannels * WfxMix.Format.wBitsPerSample / 8; ++ _recSampleRate = WfxMix.Format.nSamplesPerSec; ++ _recBlockSize = WfxMix.Format.nSamplesPerSec / 100; ++ ++ RTC_LOG(LS_INFO) << "Capturing in the mix format: nChannels=" ++ << WfxMix.Format.nChannels ++ << ", nSamplesPerSec=" << WfxMix.Format.nSamplesPerSec ++ << ", dwChannelMask=0x" ++ << ToHex(static_cast(WfxMix.dwChannelMask)) ++ << ", converted to " << _recChannels << " channel(s)"; + } + + // Create a capturing stream. +@@ -2246,10 +2525,10 @@ + AUDCLNT_STREAMFLAGS_NOPERSIST, // volume and mute settings for an + // audio session will not persist + // across system restarts +- 0, // required for event-driven shared mode +- 0, // periodicity +- (WAVEFORMATEX*)&Wfx, // selected wave format +- NULL); // session GUID ++ 0, // required for event-driven shared mode ++ 0, // periodicity ++ (WAVEFORMATEX*)pWfxSelected, // selected wave format ++ NULL); // session GUID + + if (hr != S_OK) { + RTC_LOG(LS_ERROR) << "IAudioClient::Initialize() failed:"; +@@ -2832,7 +3111,14 @@ + } + + // Get the actual (stored) data +- nSamples = _ptrAudioBuffer->GetPlayoutData((int8_t*)pData); ++ if (_playConversion.empty()) { ++ nSamples = _ptrAudioBuffer->GetPlayoutData((int8_t*)pData); ++ } else { ++ nSamples = ++ _ptrAudioBuffer->GetPlayoutData(_playConvertBuffer.data()); ++ ConvertChannels(_playConversion, _playConvertBuffer.data(), ++ reinterpret_cast(pData), _playBlockSize); ++ } + } + + DWORD dwFlags(0); +@@ -3221,7 +3507,13 @@ + + RTC_DCHECK_NE(framesAvailable, 0); + +- if (pData) { ++ if (pData && !_recConversion.empty()) { ++ ConvertChannels(_recConversion, ++ reinterpret_cast(pData), ++ reinterpret_cast( ++ &syncBuffer[syncBufIndex * _recAudioFrameSize]), ++ framesAvailable); ++ } else if (pData) { + CopyMemory(&syncBuffer[syncBufIndex * _recAudioFrameSize], pData, + framesAvailable * _recAudioFrameSize); + } else { +--- a/modules/audio_device/win/audio_device_core_win.h ++++ b/modules/audio_device/win/audio_device_core_win.h +@@ -21,6 +21,9 @@ + #include // IMediaObject + #include // MMDevice + ++#include ++#include ++ + #include "api/scoped_refptr.h" + #include "modules/audio_device/audio_device_generic.h" + #include "rtc_base/synchronization/mutex.h" +@@ -43,6 +46,27 @@ + const uint16_t CORE_SPEAKER_VOLUME_STEP_SIZE = 1; + const uint16_t CORE_MICROPHONE_VOLUME_STEP_SIZE = 1; + ++// A conversion of interleaved 16-bit audio from one channel count to ++// another, built from a channel mixing matrix. A matrix that only routes ++// channels becomes a channel map, and the samples are copied, as Chrome's ++// ChannelMixer does. Any other matrix becomes the list of non-zero terms of ++// each output channel, with integer coefficients, as Firefox's cubeb mixer ++// applies it to 16-bit audio. ++struct CoreAudioChannelConversion { ++ // Channels per input frame; 0 when there is nothing to convert. ++ size_t input_channels = 0; ++ // For a matrix that only routes channels: the input channel of each output ++ // channel, or -1 for silence. Empty otherwise. ++ std::vector channel_map; ++ // For any other matrix: the (input channel, coefficient) pairs of each ++ // output channel, the coefficients in fixed point with 24 fractional bits. ++ std::vector>> terms; ++ // Whether a sum of terms can leave the 16-bit range, and must be clamped. ++ bool clipping = false; ++ ++ bool empty() const { return input_channels == 0; } ++}; ++ + class AudioDeviceWindowsCore : public AudioDeviceGeneric { + public: + AudioDeviceWindowsCore(); +@@ -270,6 +294,15 @@ + uint16_t _recChannelsPrioList[3]; + uint16_t _playChannelsPrioList[2]; + ++ // Channel conversion for a device that is opened in its own mix format, ++ // because it accepts none of the formats above: from WebRTC's mono or ++ // stereo to the device's channels (render), or back (capture). Empty when ++ // no conversion is needed. `_playConvertBuffer` holds one 10 ms block in ++ // WebRTC's channel count before it is converted. ++ CoreAudioChannelConversion _playConversion; ++ CoreAudioChannelConversion _recConversion; ++ std::vector _playConvertBuffer; ++ + LARGE_INTEGER _perfCounterFreq; + double _perfCounterFactor; + diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/PlayoutIntegrationTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/PlayoutIntegrationTest.java new file mode 100644 index 000000000..b13220d18 --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/PlayoutIntegrationTest.java @@ -0,0 +1,110 @@ +/* + * 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import dev.onvoid.webrtc.media.MediaDevices; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +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; + +/** + * Plays out on the default speaker through the platform audio device module. + * Needs a real playout device and is skipped without one. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Execution(ExecutionMode.SAME_THREAD) +class PlayoutIntegrationTest { + + @Test + @EnabledOnOs(OS.WINDOWS) + void playoutOnDefaultSpeaker() throws Exception { + AudioDevice speaker = null; + + try { + speaker = MediaDevices.getDefaultAudioRenderDevice(); + } + catch (RuntimeException e) { + // No audio system on this host. + } + + assumeTrue(speaker != null, "no playout device"); + + // WebRTC exchanges mono or stereo 10 ms blocks with the module, whatever + // the device mixes in. A device whose shared-mode mix format has more + // channels, as gaming headsets that mix in 7.1 have, accepts no stereo + // stream, so the module opens it in its mix format and converts. Without + // that, initPlayout() fails on such a device and no block arrives. + CountDownLatch blocks = new CountDownLatch(20); + AtomicReference wrongFormat = new AtomicReference<>(); + + AudioDeviceModule module = new AudioDeviceModule(); + + try { + module.setPlayoutDevice(speaker); + module.setAudioSource(new AudioSource() { + + @Override + public int onPlaybackData(byte[] audioSamples, int nSamples, + int nBytesPerSample, int nChannels, int samplesPerSec) { + // This runs on the module's render thread, where a thrown + // assertion is lost and even printing fails under Surefire, + // so the format is recorded and checked on the test thread. + if (nChannels < 1 || nChannels > 2 + || nBytesPerSample != 2 * nChannels + || nSamples * 100 != samplesPerSec) { + wrongFormat.compareAndSet(null, String.format( + "%d frames, %d bytes per frame, %d channels, %d Hz", + nSamples, nBytesPerSample, nChannels, + samplesPerSec)); + } + + blocks.countDown(); + + // The buffer is silent; play it. + return nSamples; + } + }); + + module.initPlayout(); + module.startPlayout(); + + try { + assertTrue(blocks.await(5, TimeUnit.SECONDS), + "playout did not run"); + assertNull(wrongFormat.get(), "unexpected playout format"); + } + finally { + module.stopPlayout(); + } + } + finally { + module.dispose(); + } + } +}