From bd010cc3817ec66c9ceef4c95f067eff794a9d3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:16:41 +0000 Subject: [PATCH 01/10] Initial plan From 308071387b731a47c9a91ffd9fb9af9116c01ca7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:22:28 +0000 Subject: [PATCH 02/10] Fix workflow, obsolete API, null refs, resource leaks, and locale-dependent parsing Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- .github/workflows/build.yml | 22 ++++++++++++++++------ AirPlay/AirPlay.csproj | 2 +- AirPlay/AirPlayReceiver.cs | 15 ++++++++++----- AirPlay/AirPlayService.cs | 5 ++++- AirPlay/Listeners/AirTunesListener.cs | 20 +++++++++++++++----- AirPlay/Listeners/StreamingListener.cs | 7 ++++--- AirPlay/Utils/Utilities.cs | 7 +++---- 7 files changed, 53 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fac76f9..0796724 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,14 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - configuration: [Debug, Release] + configuration: [Release] + include: + - os: ubuntu-latest + rid: linux-x64 + - os: windows-latest + rid: win-x64 + - os: macos-latest + rid: osx-x64 steps: - name: Checkout code @@ -32,8 +39,11 @@ jobs: - name: Build run: dotnet build AirPlay.sln --configuration ${{ matrix.configuration }} --no-restore - - name: Display build output location - shell: bash - run: | - echo "Build completed for ${{ matrix.os }} - ${{ matrix.configuration }}" - find AirPlay/bin/${{ matrix.configuration }} -type f -name "AirPlay.dll" || true + - name: Publish + run: dotnet publish AirPlay/AirPlay.csproj --configuration ${{ matrix.configuration }} --runtime ${{ matrix.rid }} --self-contained true --output ./publish/${{ matrix.rid }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: AirPlay-${{ matrix.rid }} + path: ./publish/${{ matrix.rid }}/ diff --git a/AirPlay/AirPlay.csproj b/AirPlay/AirPlay.csproj index c0299f7..ab01dc3 100644 --- a/AirPlay/AirPlay.csproj +++ b/AirPlay/AirPlay.csproj @@ -3,7 +3,7 @@ Exe net8.0 - $(NoWarn);SYSLIB0011 + $(NoWarn);SYSLIB0011;SYSLIB0050 diff --git a/AirPlay/AirPlayReceiver.cs b/AirPlay/AirPlayReceiver.cs index 3f9002f..6289e35 100644 --- a/AirPlay/AirPlayReceiver.cs +++ b/AirPlay/AirPlayReceiver.cs @@ -13,7 +13,7 @@ namespace AirPlay { - public class AirPlayReceiver : IRtspReceiver, IAirPlayReceiver + public class AirPlayReceiver : IRtspReceiver, IAirPlayReceiver, IDisposable { public event EventHandler OnSetVolumeReceived; public event EventHandler OnH264DataReceived; @@ -137,9 +137,14 @@ public void OnData(H264Data data) OnH264DataReceived?.Invoke(this, data); } - public void OnPCMData(PcmData data) - { - OnPCMDataReceived?.Invoke(this, data); - } + public void OnPCMData(PcmData data) + { + OnPCMDataReceived?.Invoke(this, data); + } + + public void Dispose() + { + _mdns?.Stop(); + } } } \ No newline at end of file diff --git a/AirPlay/AirPlayService.cs b/AirPlay/AirPlayService.cs index df1c87a..395368d 100644 --- a/AirPlay/AirPlayService.cs +++ b/AirPlay/AirPlayService.cs @@ -100,7 +100,10 @@ public Task StopAsync(CancellationToken cancellationToken) public void Dispose() { - + if (_airPlayReceiver is IDisposable disposable) + { + disposable.Dispose(); + } } } } diff --git a/AirPlay/Listeners/AirTunesListener.cs b/AirPlay/Listeners/AirTunesListener.cs index 96d7249..6a17b24 100644 --- a/AirPlay/Listeners/AirTunesListener.cs +++ b/AirPlay/Listeners/AirTunesListener.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Runtime.Serialization.Plists; @@ -9,7 +10,7 @@ using System.Threading.Tasks; using AirPlay.DmapTagged; using AirPlay.Models; -using AirPlay.Models.Configs; +using AirPlay.Models.Configs; using AirPlay.Models.Enums; using AirPlay.Services.Implementations; using AirPlay.Utils; @@ -485,7 +486,7 @@ public override async Task OnDataReceivedAsync(Request request, Response respons if (key.Equals("volume", StringComparison.OrdinalIgnoreCase)) { // request.Body contains 'volume: N.NNNNNN' - _receiver.OnSetVolume(decimal.Parse(val)); + _receiver.OnSetVolume(decimal.Parse(val, CultureInfo.InvariantCulture)); } else if (key.Equals("progress", StringComparison.OrdinalIgnoreCase)) { @@ -541,7 +542,10 @@ public override async Task OnDataReceivedAsync(Request request, Response respons } } - await session.AudioControlListener.FlushAsync(next_seq); + if (session.AudioControlListener != null) + { + await session.AudioControlListener.FlushAsync(next_seq); + } } if (request.Type == RequestType.TEARDOWN) { @@ -560,13 +564,19 @@ public override async Task OnDataReceivedAsync(Request request, Response respons if (type == 110) { // Stop mirroring session - await session.MirroringListener.StopAsync(); + if (session.MirroringListener != null) + { + await session.MirroringListener.StopAsync(); + } } // If audio session if (type == 96) { // Stop audio session - await session.AudioControlListener.StopAsync(); + if (session.AudioControlListener != null) + { + await session.AudioControlListener.StopAsync(); + } } } } diff --git a/AirPlay/Listeners/StreamingListener.cs b/AirPlay/Listeners/StreamingListener.cs index d36bebe..45598b9 100644 --- a/AirPlay/Listeners/StreamingListener.cs +++ b/AirPlay/Listeners/StreamingListener.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; -using System.IO; +using System.Globalization; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; @@ -138,7 +139,7 @@ public override async Task OnDataReceivedAsync(Request request, Response respons return new KeyValuePair(splitted[0], splitted[1]); }).ToDictionary(k => k.Key, v => v.Value); - var startAt = dict.TryGetValue("Start-Position", out string dStartAt) ? decimal.Parse(dStartAt) : 0M; + var startAt = dict.TryGetValue("Start-Position", out string dStartAt) ? decimal.Parse(dStartAt, CultureInfo.InvariantCulture) : 0M; var url = dict.TryGetValue("Content-Location", out string dUrl) ? dUrl : throw new ArgumentNullException(nameof(dUrl)); // DO SOMETHING HERE... diff --git a/AirPlay/Utils/Utilities.cs b/AirPlay/Utils/Utilities.cs index 335a70a..2e0e6a7 100644 --- a/AirPlay/Utils/Utilities.cs +++ b/AirPlay/Utils/Utilities.cs @@ -24,9 +24,8 @@ public static byte[] CopyOfRange(byte[] src, int start, int end) public static byte[] Hash(byte[] first, byte[] last) { - var sha512 = new SHA512CryptoServiceProvider(); byte[] combined = first.Concat(last).ToArray(); - byte[] hashed = sha512.ComputeHash(combined); + byte[] hashed = SHA512.HashData(combined); return hashed; } @@ -57,9 +56,9 @@ public static void Swap(byte[] arr, int idxA, int idxB) public static byte[] WriteWavHeader(ushort numchannels, uint sampleRate, ushort bitsPerSample, uint tot) { - var stream = new MemoryStream(); + using var stream = new MemoryStream(); + using BinaryWriter bwl = new BinaryWriter(stream); - BinaryWriter bwl = new BinaryWriter(stream); bwl.Write(new char[4] { 'R', 'I', 'F', 'F' }); bwl.Write(tot + 38); bwl.Write(new char[8] { 'W', 'A', 'V', 'E', 'f', 'm', 't', ' ' }); From cc491a579ac8ae5aa2e66f4e3db7bf8c180c849a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:25:10 +0000 Subject: [PATCH 03/10] Simplify workflow to only build Windows (win-x64) version Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- .github/workflows/build.yml | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0796724..b7de20f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,20 +9,9 @@ on: jobs: build: - runs-on: ${{ matrix.os }} + runs-on: windows-latest permissions: contents: read - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - configuration: [Release] - include: - - os: ubuntu-latest - rid: linux-x64 - - os: windows-latest - rid: win-x64 - - os: macos-latest - rid: osx-x64 steps: - name: Checkout code @@ -37,13 +26,13 @@ jobs: run: dotnet restore AirPlay.sln - name: Build - run: dotnet build AirPlay.sln --configuration ${{ matrix.configuration }} --no-restore + run: dotnet build AirPlay.sln --configuration Release --no-restore - name: Publish - run: dotnet publish AirPlay/AirPlay.csproj --configuration ${{ matrix.configuration }} --runtime ${{ matrix.rid }} --self-contained true --output ./publish/${{ matrix.rid }} + run: dotnet publish AirPlay/AirPlay.csproj --configuration Release --runtime win-x64 --self-contained true --output ./publish/win-x64 - name: Upload build artifacts uses: actions/upload-artifact@v4 with: - name: AirPlay-${{ matrix.rid }} - path: ./publish/${{ matrix.rid }}/ + name: AirPlay-win-x64 + path: ./publish/win-x64/ From a47b2096a0ebd5d6d093f75f5da4df317444879b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:32:20 +0000 Subject: [PATCH 04/10] Fix NETSDK1152 publish error: remove unused Curve25519 package and add ErrorOnDuplicatePublishOutputFiles Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- AirPlay/AirPlay.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AirPlay/AirPlay.csproj b/AirPlay/AirPlay.csproj index ab01dc3..9558c24 100644 --- a/AirPlay/AirPlay.csproj +++ b/AirPlay/AirPlay.csproj @@ -4,6 +4,7 @@ Exe net8.0 $(NoWarn);SYSLIB0011;SYSLIB0050 + false @@ -24,7 +25,6 @@ - From 985c708c96665e3a6fb88a6a524462796ecc08a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:45:48 +0000 Subject: [PATCH 05/10] Replace native codec libraries with managed NuGet packages for audio decoding - Replace native FDK-AAC (libfdk-aac-2.dll) with managed SharpJaad.AAC NuGet package - Replace native libalac (libalac-0.dll) with managed LibALAC NuGet package - Remove CodecLibrariesConfig and library path configuration (no longer needed) - Remove CodecLibraries sections from all appsettings JSON files - Update AACDecoder, ALACDecoder, AudioListener, AirTunesListener, AirPlayReceiver, Program.cs Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- AirPlay/AirPlay.csproj | 2 + AirPlay/AirPlayReceiver.cs | 21 +- .../Decoders/Implementations/AACDecoder.cs | 215 ++++++------------ .../Decoders/Implementations/ALACDecoder.cs | 69 ++---- AirPlay/Listeners/AirTunesListener.cs | 6 +- AirPlay/Listeners/AudioListener.cs | 10 +- AirPlay/Program.cs | 1 - AirPlay/appsettings_linux.json | 4 - AirPlay/appsettings_osx.json | 4 - AirPlay/appsettings_win.json | 4 - 10 files changed, 99 insertions(+), 237 deletions(-) diff --git a/AirPlay/AirPlay.csproj b/AirPlay/AirPlay.csproj index 9558c24..c2186a9 100644 --- a/AirPlay/AirPlay.csproj +++ b/AirPlay/AirPlay.csproj @@ -17,6 +17,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/AirPlay/AirPlayReceiver.cs b/AirPlay/AirPlayReceiver.cs index 6289e35..85c274d 100644 --- a/AirPlay/AirPlayReceiver.cs +++ b/AirPlay/AirPlayReceiver.cs @@ -29,17 +29,16 @@ public class AirPlayReceiver : IRtspReceiver, IAirPlayReceiver, IDisposable private readonly ushort _airPlayPort; private readonly string _deviceId; - public AirPlayReceiver(IOptions aprConfig, IOptions codecConfig, IOptions dumpConfig) - { - _airTunesPort = aprConfig?.Value?.AirTunesPort ?? 5000; - _airPlayPort = aprConfig?.Value?.AirPlayPort ?? 7000; - _deviceId = aprConfig?.Value?.DeviceMacAddress ?? "11:22:33:44:55:66"; - _instance = aprConfig?.Value?.Instance ?? throw new ArgumentNullException("apr.instance"); - - var clConfig = codecConfig?.Value ?? throw new ArgumentNullException(nameof(codecConfig)); - var dConfig = dumpConfig?.Value ?? throw new ArgumentNullException(nameof(dumpConfig)); - - _airTunesListener = new AirTunesListener(this, _airTunesPort, _airPlayPort, clConfig, dConfig); + public AirPlayReceiver(IOptions aprConfig, IOptions dumpConfig) + { + _airTunesPort = aprConfig?.Value?.AirTunesPort ?? 5000; + _airPlayPort = aprConfig?.Value?.AirPlayPort ?? 7000; + _deviceId = aprConfig?.Value?.DeviceMacAddress ?? "11:22:33:44:55:66"; + _instance = aprConfig?.Value?.Instance ?? throw new ArgumentNullException("apr.instance"); + + var dConfig = dumpConfig?.Value ?? throw new ArgumentNullException(nameof(dumpConfig)); + + _airTunesListener = new AirTunesListener(this, _airTunesPort, _airPlayPort, dConfig); } public async Task StartListeners(CancellationToken cancellationToken) diff --git a/AirPlay/Decoders/Implementations/AACDecoder.cs b/AirPlay/Decoders/Implementations/AACDecoder.cs index e3de90d..5c79931 100644 --- a/AirPlay/Decoders/Implementations/AACDecoder.cs +++ b/AirPlay/Decoders/Implementations/AACDecoder.cs @@ -1,199 +1,114 @@ /* - * I have mapped only used methods. - * This code does not have all 'AAC Decoder' functionality + * AAC Decoder using SharpJaad.AAC managed library. + * No native library dependencies required. */ using System; -using System.IO; using System.Runtime.InteropServices; using AirPlay.Models.Enums; -using AirPlay.Utils; +using SharpJaad.AAC; namespace AirPlay { - public unsafe class AACDecoder : IDecoder, IDisposable + public class AACDecoder : IDecoder { - private IntPtr _handle; - private IntPtr _decoder; + private Decoder _jaadDecoder; + private DecoderConfig _decoderConfig; + private SampleBuffer _outputBuffer = new SampleBuffer(); - private delegate IntPtr aacDecoder_Open(int transportFmt, uint nrOfLayers); - private delegate AACDecoderError aacDecoder_ConfigRaw(IntPtr decoder, IntPtr[] conf, uint *length); - private delegate AACDecoderError aacDecoder_Fill(IntPtr decoder, IntPtr[] pBuffer, uint *bufferSize, uint *pBytesValid); - private delegate AACDecoderError aacDecoder_DecodeFrame(IntPtr decoder, IntPtr output, int pcm_pkt_size, uint flags); - private delegate IntPtr aacDecoder_Close(IntPtr decoder); + private int _pcmPktSize; - private aacDecoder_Open _aacDecoder_Open; - private aacDecoder_ConfigRaw _aacDecoder_ConfigRaw; - private aacDecoder_Fill _aacDecoder_Fill; - private aacDecoder_DecodeFrame _aacDecoder_DecodeFrame; - private aacDecoder_Close _aacDecoder_Close; + public AudioFormat Type => AudioFormat.AAC; - private AudioObjectType _audioObjectType; - - public int _pcmPktSize; - - public AudioFormat Type => _audioObjectType == AudioObjectType.AOT_ER_AAC_ELD ? AudioFormat.AAC_ELD : AudioFormat.AAC; - - public AACDecoder(string libraryPath, TransportType transportFmt, AudioObjectType audioObjectType, uint nrOfLayers) + public AACDecoder(TransportType transportFmt, AudioObjectType audioObjectType, uint nrOfLayers) { - if (!File.Exists(libraryPath)) + _decoderConfig = new DecoderConfig(); + if (GetProfileFromAudioObjectType(audioObjectType) is Profile profile) { - throw new IOException("Library not found."); + _decoderConfig.SetProfile(profile); + } + else + { + throw new NotSupportedException($"AudioObjectType {audioObjectType} is not supported."); } - - // Open library - _handle = LibraryLoader.DlOpen(libraryPath, 0); - - // Get function pointers symbols - IntPtr symAacDecoder_Open = LibraryLoader.DlSym(_handle, "aacDecoder_Open"); - IntPtr symAacDecoder_ConfigRaw = LibraryLoader.DlSym(_handle, "aacDecoder_ConfigRaw"); - IntPtr sysAacDecoder_GetStreamInfo = LibraryLoader.DlSym(_handle, "aacDecoder_GetStreamInfo"); - IntPtr sysAacDecoder_Fill = LibraryLoader.DlSym(_handle, "aacDecoder_Fill"); - IntPtr sysAacDecoder_DecodeFrame = LibraryLoader.DlSym(_handle, "aacDecoder_DecodeFrame"); - IntPtr sysAacDecoder_Close = LibraryLoader.DlSym(_handle, "aacDecoder_Close"); - - // Get delegates for the function pointers - _aacDecoder_Open = Marshal.GetDelegateForFunctionPointer(symAacDecoder_Open); - _aacDecoder_ConfigRaw = Marshal.GetDelegateForFunctionPointer(symAacDecoder_ConfigRaw); - _aacDecoder_Fill = Marshal.GetDelegateForFunctionPointer(sysAacDecoder_Fill); - _aacDecoder_DecodeFrame = Marshal.GetDelegateForFunctionPointer(sysAacDecoder_DecodeFrame); - _aacDecoder_Close = Marshal.GetDelegateForFunctionPointer(sysAacDecoder_Close); - - _decoder = _aacDecoder_Open((int)transportFmt, nrOfLayers); - - _audioObjectType = audioObjectType; } public int Config(int sampleRate, int channels, int bitDepth, int frameLength) { _pcmPktSize = frameLength * channels * bitDepth / 8; - var frequencyIndex = Enum.Parse($"F_{sampleRate}", true); - - var config = AudioSpecificConfig((int)_audioObjectType, (int)frequencyIndex, channels, bitDepth); - - return Config(config); - } - - public int GetOutputStreamLength() - { - return _pcmPktSize; - } - - public int DecodeFrame(byte[] input, ref byte[] output, int pcm_pkt_size) - { - AACDecoderError ret; - uint pkt_size = (uint)input.Length; - uint valid_size = (uint)input.Length; - uint fdk_flags = 0; - - ret = Fill(_decoder, input, pkt_size, valid_size); - if(ret != AACDecoderError.AAC_DEC_OK) + var jaadSampleRate = SampleFrequencyExtensions.FromFrequency(sampleRate); + _decoderConfig.SetSampleFrequency(jaadSampleRate); + if (GetChannelConfigurationFromChannelCount(channels) is ChannelConfiguration channelConfig) { - Console.WriteLine($"aacDecoder_Fill error: {ret}"); - return (int)ret; + _decoderConfig.SetChannelConfiguration(channelConfig); } - - ret = InternalDecodeFrame(ref output, pcm_pkt_size, fdk_flags); - if (ret != AACDecoderError.AAC_DEC_OK) + else { - Console.WriteLine($"aacDecoder_DecodeFrame error: {ret}"); - return (int)ret; + throw new NotSupportedException($"Channel count {channels} is not supported."); } - return (int)ret; - } - - public void Dispose() - { - _aacDecoder_Close(_decoder); - LibraryLoader.DlClose(_handle); - Marshal.FreeBSTR(_handle); + _jaadDecoder = new Decoder(_decoderConfig); + return 0; } - private AACDecoderError Fill(IntPtr decoder, byte[] pBuffer, uint bufferSize, uint pBytesValid) + public int GetOutputStreamLength() { - var size = Marshal.SizeOf(pBuffer[0]) * pBuffer.Length; - var ptr = Marshal.AllocHGlobal(size); - Marshal.Copy(pBuffer, 0, ptr, pBuffer.Length); - - var byteArrayPtr = new IntPtr[] - { - ptr - }; - - var res = _aacDecoder_Fill(decoder, byteArrayPtr, &bufferSize, &pBytesValid); - - return res; + return _pcmPktSize; } - private AACDecoderError InternalDecodeFrame(ref byte[] output, int pcm_pkt_size, uint flags) + public int DecodeFrame(byte[] input, ref byte[] output, int length) { - int size = Marshal.SizeOf(output[0]) * output.Length; - IntPtr ptr = Marshal.AllocHGlobal(size); - - AACDecoderError res; - try + if (_jaadDecoder == null) { - res = _aacDecoder_DecodeFrame(_decoder, ptr, pcm_pkt_size, flags); - if (res == AACDecoderError.AAC_DEC_OK) - { - Marshal.Copy(ptr, output, 0, pcm_pkt_size); - } + throw new InvalidOperationException("Decoder is not configured. Call Config() before decoding."); } - finally + _jaadDecoder.DecodeFrame(input, _outputBuffer); + var data = _outputBuffer.Data; + if (data != null && data.Length > 0) { - if (ptr != IntPtr.Zero) - Marshal.FreeHGlobal(ptr); + var copyLen = Math.Min(data.Length, output.Length); + Array.Copy(data, output, copyLen); } - - return res; + return 0; } - private byte[] AudioSpecificConfig(int audioObjectType, int frequenceIndex, int channels, int bitDepth) + private static Profile? GetProfileFromAudioObjectType(AudioObjectType audioObjectType) { - string bin; - if (audioObjectType >= 31) + return audioObjectType switch { - bin = Convert.ToString(31, 2).PadLeft(5, '0'); - bin += Convert.ToString(audioObjectType - 32, 2).PadLeft(6, '0'); - } - else - { - bin = Convert.ToString(audioObjectType, 2).PadLeft(5, '0'); - } - - bin += Convert.ToString(frequenceIndex, 2).PadLeft(4, '0'); - bin += Convert.ToString(channels, 2).PadLeft(4, '0'); - bin += Convert.ToString(bitDepth, 2).PadLeft(5, '0'); - bin += "00000000"; - - int nBytes = bin.Length / 8; - byte[] bytes = new byte[nBytes]; - for (int i = 0; i < nBytes; i++) - { - bytes[i] = Convert.ToByte(bin.Substring(8 * i, 8), 2); - } - - return bytes; + AudioObjectType.AOT_AAC_MAIN => Profile.AAC_MAIN, + AudioObjectType.AOT_AAC_LC => Profile.AAC_LC, + AudioObjectType.AOT_AAC_SSR => Profile.AAC_SSR, + AudioObjectType.AOT_AAC_LTP => Profile.AAC_LTP, + AudioObjectType.AOT_SBR => Profile.AAC_SBR, + AudioObjectType.AOT_AAC_SCAL => Profile.AAC_SCALABLE, + AudioObjectType.AOT_TWIN_VQ => Profile.TWIN_VQ, + AudioObjectType.AOT_ER_AAC_LC => Profile.ER_AAC_LC, + AudioObjectType.AOT_ER_AAC_LTP => Profile.ER_AAC_LTP, + AudioObjectType.AOT_ER_AAC_SCAL => Profile.ER_AAC_SCALABLE, + AudioObjectType.AOT_ER_TWIN_VQ => Profile.ER_TWIN_VQ, + AudioObjectType.AOT_ER_BSAC => Profile.ER_BSAC, + AudioObjectType.AOT_ER_AAC_LD => Profile.ER_AAC_LD, + _ => null + }; } - private int Config(byte[] config) + private static ChannelConfiguration? GetChannelConfigurationFromChannelCount(int channels) { - uint length = (uint)config.Length; - - var size = Marshal.SizeOf(config[0]) * config.Length; - var ptr = Marshal.AllocHGlobal(size); - Marshal.Copy(config, 0, ptr, config.Length); - - var byteArrayPtr = new IntPtr[] + return channels switch { - ptr + 0 => ChannelConfiguration.CHANNEL_CONFIG_NONE, + 1 => ChannelConfiguration.CHANNEL_CONFIG_MONO, + 2 => ChannelConfiguration.CHANNEL_CONFIG_STEREO, + 3 => ChannelConfiguration.CHANNEL_CONFIG_STEREO_PLUS_CENTER, + 4 => ChannelConfiguration.CHANNEL_CONFIG_STEREO_PLUS_CENTER_PLUS_REAR_MONO, + 5 => ChannelConfiguration.CHANNEL_CONFIG_FIVE, + 6 => ChannelConfiguration.CHANNEL_CONFIG_FIVE_PLUS_ONE, + 7 => ChannelConfiguration.CHANNEL_CONFIG_SEVEN_PLUS_ONE, + 8 => ChannelConfiguration.CHANNEL_CONFIG_SEVEN_PLUS_ONE, + _ => null }; - - var res = _aacDecoder_ConfigRaw(_decoder, byteArrayPtr, &length); - - return (int)res; } } diff --git a/AirPlay/Decoders/Implementations/ALACDecoder.cs b/AirPlay/Decoders/Implementations/ALACDecoder.cs index 681fa83..3cb8f01 100644 --- a/AirPlay/Decoders/Implementations/ALACDecoder.cs +++ b/AirPlay/Decoders/Implementations/ALACDecoder.cs @@ -1,57 +1,35 @@ /* - * I have mapped only used methods. - * This code does not have all 'ALAC Decoder' functionality + * ALAC Decoder using LibALAC managed library. + * No native library dependencies required. */ using System; -using System.IO; -using System.Runtime.InteropServices; using AirPlay.Models.Enums; -using AirPlay.Utils; namespace AirPlay { - public unsafe class ALACDecoder : IDecoder, IDisposable + public class ALACDecoder : IDecoder { - private IntPtr _handle; - private IntPtr _decoder; - - private delegate IntPtr alacDecoder_InitializeDecoder(int sampleRate, int channels, int bitsPerSample, int framesPerPacket); - private delegate int alacDecoder_DecodeFrame(IntPtr decoder, IntPtr inBuffer, IntPtr outBuffer, int* ioNumBytes); - - private alacDecoder_InitializeDecoder _alacDecoder_InitializeDecoder; - private alacDecoder_DecodeFrame _alacDecoder_DecodeFrame; - private int _pcm_pkt_size = 0; + private LibALAC.Decoder _alacDecoder; + public AudioFormat Type => AudioFormat.ALAC; - public ALACDecoder(string libraryPath) + public ALACDecoder() { - if (!File.Exists(libraryPath)) - { - throw new IOException("Library not found."); - } - - // Open library - _handle = LibraryLoader.DlOpen(libraryPath, 0); - - // Get a function pointer symbol - IntPtr symAlacDecoder_InitializeDecoder = LibraryLoader.DlSym(_handle, "InitializeDecoder"); - IntPtr symAlacDecoder_DecodeFrame = LibraryLoader.DlSym(_handle, "Decode"); - - // Get a delegate for the function pointer - _alacDecoder_InitializeDecoder = Marshal.GetDelegateForFunctionPointer(symAlacDecoder_InitializeDecoder); - _alacDecoder_DecodeFrame = Marshal.GetDelegateForFunctionPointer(symAlacDecoder_DecodeFrame); } public int Config(int sampleRate, int channels, int bitDepth, int frameLength) { _pcm_pkt_size = frameLength * channels * bitDepth / 8; - _decoder = _alacDecoder_InitializeDecoder(sampleRate, channels, bitDepth, frameLength); - - return _decoder != IntPtr.Zero ? 0 : -1; + _alacDecoder = new LibALAC.Decoder(sampleRate, channels, bitDepth, frameLength); + if (_alacDecoder == null) + { + return -1; + } + return 0; } public int GetOutputStreamLength() @@ -61,27 +39,12 @@ public int GetOutputStreamLength() public int DecodeFrame(byte[] input, ref byte[] output, int outputLen) { - var size = Marshal.SizeOf(input[0]) * input.Length; - var inputPtr = Marshal.AllocHGlobal(size); - Marshal.Copy(input, 0, inputPtr, input.Length); - - var outSize = Marshal.SizeOf(output[0]) * output.Length; - var outPtr = Marshal.AllocHGlobal(outSize); - - var res = _alacDecoder_DecodeFrame(_decoder, inputPtr, outPtr, &outputLen); - if(res == 0) + if (_alacDecoder == null) { - Marshal.Copy(outPtr, output, 0, outputLen); + throw new InvalidOperationException("Decoder is not initialized. Call Config() first."); } - - return res; - } - - public void Dispose() - { - // Close the C++ library - LibraryLoader.DlClose(_handle); - Marshal.FreeBSTR(_handle); + output = _alacDecoder.Decode(input, input.Length); + return 0; } } diff --git a/AirPlay/Listeners/AirTunesListener.cs b/AirPlay/Listeners/AirTunesListener.cs index 6a17b24..e7fe363 100644 --- a/AirPlay/Listeners/AirTunesListener.cs +++ b/AirPlay/Listeners/AirTunesListener.cs @@ -33,15 +33,13 @@ public class AirTunesListener : BaseTcpListener new byte[] { 0x46,0x50,0x4c,0x59,0x03,0x01,0x02,0x00,0x00,0x00,0x00,0x82,0x02,0x02,0xc1,0x69,0xa3,0x52,0xee,0xed,0x35,0xb1,0x8c,0xdd,0x9c,0x58,0xd6,0x4f,0x16,0xc1,0x51,0x9a,0x89,0xeb,0x53,0x17,0xbd,0x0d,0x43,0x36,0xcd,0x68,0xf6,0x38,0xff,0x9d,0x01,0x6a,0x5b,0x52,0xb7,0xfa,0x92,0x16,0xb2,0xb6,0x54,0x82,0xc7,0x84,0x44,0x11,0x81,0x21,0xa2,0xc7,0xfe,0xd8,0x3d,0xb7,0x11,0x9e,0x91,0x82,0xaa,0xd7,0xd1,0x8c,0x70,0x63,0xe2,0xa4,0x57,0x55,0x59,0x10,0xaf,0x9e,0x0e,0xfc,0x76,0x34,0x7d,0x16,0x40,0x43,0x80,0x7f,0x58,0x1e,0xe4,0xfb,0xe4,0x2c,0xa9,0xde,0xdc,0x1b,0x5e,0xb2,0xa3,0xaa,0x3d,0x2e,0xcd,0x59,0xe7,0xee,0xe7,0x0b,0x36,0x29,0xf2,0x2a,0xfd,0x16,0x1d,0x87,0x73,0x53,0xdd,0xb9,0x9a,0xdc,0x8e,0x07,0x00,0x6e,0x56,0xf8,0x50,0xce}, new byte[] { 0x46,0x50,0x4c,0x59,0x03,0x01,0x02,0x00,0x00,0x00,0x00,0x82,0x02,0x03,0x90,0x01,0xe1,0x72,0x7e,0x0f,0x57,0xf9,0xf5,0x88,0x0d,0xb1,0x04,0xa6,0x25,0x7a,0x23,0xf5,0xcf,0xff,0x1a,0xbb,0xe1,0xe9,0x30,0x45,0x25,0x1a,0xfb,0x97,0xeb,0x9f,0xc0,0x01,0x1e,0xbe,0x0f,0x3a,0x81,0xdf,0x5b,0x69,0x1d,0x76,0xac,0xb2,0xf7,0xa5,0xc7,0x08,0xe3,0xd3,0x28,0xf5,0x6b,0xb3,0x9d,0xbd,0xe5,0xf2,0x9c,0x8a,0x17,0xf4,0x81,0x48,0x7e,0x3a,0xe8,0x63,0xc6,0x78,0x32,0x54,0x22,0xe6,0xf7,0x8e,0x16,0x6d,0x18,0xaa,0x7f,0xd6,0x36,0x25,0x8b,0xce,0x28,0x72,0x6f,0x66,0x1f,0x73,0x88,0x93,0xce,0x44,0x31,0x1e,0x4b,0xe6,0xc0,0x53,0x51,0x93,0xe5,0xef,0x72,0xe8,0x68,0x62,0x33,0x72,0x9c,0x22,0x7d,0x82,0x0c,0x99,0x94,0x45,0xd8,0x92,0x46,0xc8,0xc3,0x59} }; - private readonly CodecLibrariesConfig _codecConfig; private readonly DumpConfig _dumpConfig; - public AirTunesListener(IRtspReceiver receiver, ushort port, ushort airPlayPort, CodecLibrariesConfig codecConfig, DumpConfig dumpConfig) : base(port) + public AirTunesListener(IRtspReceiver receiver, ushort port, ushort airPlayPort, DumpConfig dumpConfig) : base(port) { _airTunesPort = port; _airPlayPort = airPlayPort; _receiver = receiver ?? throw new ArgumentNullException(nameof(receiver)); - _codecConfig = codecConfig ?? throw new ArgumentNullException(nameof(codecConfig)); _dumpConfig = dumpConfig ?? throw new ArgumentNullException(nameof(dumpConfig)); // First time that we instantiate AirPlayListener we must create a ED25519 KeyPair @@ -445,7 +443,7 @@ public override async Task OnDataReceivedAsync(Request request, Response respons if (session.FairPlayReady && session.AudioSessionReady && session.AudioControlListener == null) { // Start 'AudioListener' (handle PCM/AAC/ALAC data received from iOS/macOS - var control = new AudioListener(_receiver, session.SessionId, 7002, 7003, _codecConfig, _dumpConfig); + var control = new AudioListener(_receiver, session.SessionId, 7002, 7003, _dumpConfig); await control.StartAsync(cancellationToken).ConfigureAwait(false); session.AudioControlListener = control; diff --git a/AirPlay/Listeners/AudioListener.cs b/AirPlay/Listeners/AudioListener.cs index d3c1487..7107cc0 100644 --- a/AirPlay/Listeners/AudioListener.cs +++ b/AirPlay/Listeners/AudioListener.cs @@ -33,14 +33,12 @@ public class AudioListener : BaseUdpListener private RaopBuffer _raopBuffer; private Socket _cSocket; - private readonly CodecLibrariesConfig _clConfig; private readonly DumpConfig _dConfig; - public AudioListener(IRtspReceiver receiver, string sessionId, ushort cport, ushort dport, CodecLibrariesConfig clConfig, DumpConfig dConfig) : base(cport, dport) + public AudioListener(IRtspReceiver receiver, string sessionId, ushort cport, ushort dport, DumpConfig dConfig) : base(cport, dport) { _receiver = receiver ?? throw new ArgumentNullException(nameof(receiver)); _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _clConfig = clConfig ?? throw new ArgumentNullException(nameof(clConfig)); _dConfig = dConfig ?? throw new ArgumentNullException(nameof(dConfig)); _raopBuffer = RaopBufferInit(); @@ -490,7 +488,7 @@ private void InitializeDecoder (AudioFormat audioFormat) var bitDepth = 16; var sampleRate = 44100; - _decoder = new ALACDecoder(_clConfig.ALACLibPath); + _decoder = new ALACDecoder(); _decoder.Config(sampleRate, numChannels, bitDepth, frameLength); } else if (audioFormat == AudioFormat.AAC) @@ -503,7 +501,7 @@ private void InitializeDecoder (AudioFormat audioFormat) var bitDepth = 16; var sampleRate = 44100; - _decoder = new AACDecoder(_clConfig.AACLibPath, TransportType.TT_MP4_RAW, AudioObjectType.AOT_AAC_MAIN, 1); + _decoder = new AACDecoder(TransportType.TT_MP4_RAW, AudioObjectType.AOT_AAC_MAIN, 1); _decoder.Config(sampleRate, numChannels, bitDepth, frameLength); } else if(audioFormat == AudioFormat.AAC_ELD) @@ -516,7 +514,7 @@ private void InitializeDecoder (AudioFormat audioFormat) var bitDepth = 16; var sampleRate = 44100; - _decoder = new AACDecoder(_clConfig.AACLibPath, TransportType.TT_MP4_RAW, AudioObjectType.AOT_ER_AAC_ELD, 1); + _decoder = new AACDecoder(TransportType.TT_MP4_RAW, AudioObjectType.AOT_ER_AAC_ELD, 1); _decoder.Config(sampleRate, numChannels, bitDepth, frameLength); } else diff --git a/AirPlay/Program.cs b/AirPlay/Program.cs index 6225118..5fedf66 100644 --- a/AirPlay/Program.cs +++ b/AirPlay/Program.cs @@ -61,7 +61,6 @@ public static async Task Main(string[] args) services.AddOptions(); services.Configure(hostContext.Configuration.GetSection("AirPlayReceiver")); - services.Configure(hostContext.Configuration.GetSection("CodecLibraries")); services.Configure(hostContext.Configuration.GetSection("Dump")); services.AddSingleton(); diff --git a/AirPlay/appsettings_linux.json b/AirPlay/appsettings_linux.json index cb4d537..bf610ca 100644 --- a/AirPlay/appsettings_linux.json +++ b/AirPlay/appsettings_linux.json @@ -10,10 +10,6 @@ "AirPlayPort": 7000, "DeviceMacAddress": "11:22:33:44:55:66" }, - "CodecLibraries": { - "AACLibPath": "/usr/local/lib/libfdk-aac.dylib", - "ALACLibPath": "/usr/local/lib/libalac.dylib" - }, "Dump": { "Path": "/path_here/" } diff --git a/AirPlay/appsettings_osx.json b/AirPlay/appsettings_osx.json index afd575a..90c53ae 100644 --- a/AirPlay/appsettings_osx.json +++ b/AirPlay/appsettings_osx.json @@ -10,10 +10,6 @@ "AirPlayPort": 7000, "DeviceMacAddress": "10:20:30:40:50:60" }, - "CodecLibraries": { - "AACLibPath": "/usr/local/lib/libfdk-aac.dylib", - "ALACLibPath": "/usr/local/lib/libalac.dylib" - }, "Dump": { "Path": "/Users/steebono/Desktop/dump/" } diff --git a/AirPlay/appsettings_win.json b/AirPlay/appsettings_win.json index 90498fb..d7440da 100644 --- a/AirPlay/appsettings_win.json +++ b/AirPlay/appsettings_win.json @@ -10,10 +10,6 @@ "AirPlayPort": 7000, "DeviceMacAddress": "11:22:33:44:55:66" }, - "CodecLibraries": { - "AACLibPath": "C:\\msys64\\home\\sbono\\fdk-aac-master\\.libs\\libfdk-aac-2.dll", - "ALACLibPath": "C:\\msys64\\mingw64\\bin\\libalac-0.dll" - }, "Dump": { "Path": "C:\\Users\\Foo\\Desktop\\Dump\\" } From 01f6ccd14232f6235625a12383a41b2751c9f9b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:48:26 +0000 Subject: [PATCH 06/10] Fix AAC_ELD type reporting and remove redundant null check in ALACDecoder Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- AirPlay/Decoders/Implementations/AACDecoder.cs | 5 ++++- AirPlay/Decoders/Implementations/ALACDecoder.cs | 4 ---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/AirPlay/Decoders/Implementations/AACDecoder.cs b/AirPlay/Decoders/Implementations/AACDecoder.cs index 5c79931..f51fc64 100644 --- a/AirPlay/Decoders/Implementations/AACDecoder.cs +++ b/AirPlay/Decoders/Implementations/AACDecoder.cs @@ -17,11 +17,13 @@ public class AACDecoder : IDecoder private SampleBuffer _outputBuffer = new SampleBuffer(); private int _pcmPktSize; + private AudioObjectType _audioObjectType; - public AudioFormat Type => AudioFormat.AAC; + public AudioFormat Type => _audioObjectType == AudioObjectType.AOT_ER_AAC_ELD ? AudioFormat.AAC_ELD : AudioFormat.AAC; public AACDecoder(TransportType transportFmt, AudioObjectType audioObjectType, uint nrOfLayers) { + _audioObjectType = audioObjectType; _decoderConfig = new DecoderConfig(); if (GetProfileFromAudioObjectType(audioObjectType) is Profile profile) { @@ -90,6 +92,7 @@ public int DecodeFrame(byte[] input, ref byte[] output, int length) AudioObjectType.AOT_ER_TWIN_VQ => Profile.ER_TWIN_VQ, AudioObjectType.AOT_ER_BSAC => Profile.ER_BSAC, AudioObjectType.AOT_ER_AAC_LD => Profile.ER_AAC_LD, + AudioObjectType.AOT_ER_AAC_ELD => Profile.ER_AAC_LD, // AAC-ELD mapped to closest supported profile _ => null }; } diff --git a/AirPlay/Decoders/Implementations/ALACDecoder.cs b/AirPlay/Decoders/Implementations/ALACDecoder.cs index 3cb8f01..fb8adcd 100644 --- a/AirPlay/Decoders/Implementations/ALACDecoder.cs +++ b/AirPlay/Decoders/Implementations/ALACDecoder.cs @@ -25,10 +25,6 @@ public int Config(int sampleRate, int channels, int bitDepth, int frameLength) _pcm_pkt_size = frameLength * channels * bitDepth / 8; _alacDecoder = new LibALAC.Decoder(sampleRate, channels, bitDepth, frameLength); - if (_alacDecoder == null) - { - return -1; - } return 0; } From 2c1e6a37de55ca950f0ad8b8182665a2b43b6693 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:07:16 +0000 Subject: [PATCH 07/10] Add NAudio-based audio playback, fix PCM buffer length bug, add flush event - Add NAudio (2.2.1) for Windows audio output via DirectSoundOut - Create StreamingWaveProvider: thread-safe IWaveProvider buffering PCM data - Create AudioOutputService: manages DirectSound with prebuffering and flush handling - Fix critical bug: pcmData.Length was hardcoded to 960 instead of actual audiobuflen - Add OnAudioFlush() to IRtspReceiver and OnAudioFlushReceived to IAirPlayReceiver - Propagate RTSP FLUSH events from AudioListener to AirPlayReceiver - Update AirPlayService to play PCM data through speakers and handle track changes Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- AirPlay/AirPlay.csproj | 1 + AirPlay/AirPlayReceiver.cs | 10 +- AirPlay/AirPlayService.cs | 69 ++++++- AirPlay/IAirPlayReceiver.cs | 1 + AirPlay/IRtspReceiver.cs | 1 + AirPlay/Listeners/AudioListener.cs | 3 +- AirPlay/Services/AudioOutputService.cs | 239 ++++++++++++++++++++++ AirPlay/Services/StreamingWaveProvider.cs | 100 +++++++++ 8 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 AirPlay/Services/AudioOutputService.cs create mode 100644 AirPlay/Services/StreamingWaveProvider.cs diff --git a/AirPlay/AirPlay.csproj b/AirPlay/AirPlay.csproj index c2186a9..7d8b8de 100644 --- a/AirPlay/AirPlay.csproj +++ b/AirPlay/AirPlay.csproj @@ -22,6 +22,7 @@ + diff --git a/AirPlay/AirPlayReceiver.cs b/AirPlay/AirPlayReceiver.cs index 85c274d..2fcb269 100644 --- a/AirPlay/AirPlayReceiver.cs +++ b/AirPlay/AirPlayReceiver.cs @@ -15,9 +15,10 @@ namespace AirPlay { public class AirPlayReceiver : IRtspReceiver, IAirPlayReceiver, IDisposable { - public event EventHandler OnSetVolumeReceived; - public event EventHandler OnH264DataReceived; + public event EventHandler OnSetVolumeReceived; + public event EventHandler OnH264DataReceived; public event EventHandler OnPCMDataReceived; + public event EventHandler OnAudioFlushReceived; public const string AirPlayType = "_airplay._tcp"; public const string AirTunesType = "_raop._tcp"; @@ -141,6 +142,11 @@ public void OnPCMData(PcmData data) OnPCMDataReceived?.Invoke(this, data); } + public void OnAudioFlush() + { + OnAudioFlushReceived?.Invoke(this, EventArgs.Empty); + } + public void Dispose() { _mdns?.Stop(); diff --git a/AirPlay/AirPlayService.cs b/AirPlay/AirPlayService.cs index 395368d..d180459 100644 --- a/AirPlay/AirPlayService.cs +++ b/AirPlay/AirPlayService.cs @@ -1,10 +1,12 @@ -using AirPlay.Models.Configs; +using AirPlay.Models.Configs; +using AirPlay.Services; using AirPlay.Utils; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -15,7 +17,9 @@ public class AirPlayService : IHostedService, IDisposable private readonly IAirPlayReceiver _airPlayReceiver; private readonly DumpConfig _dConfig; + private AudioOutputService _audioOutput; private List _audiobuf; + private readonly object _audioOutputLock = new object(); public AirPlayService(IAirPlayReceiver airPlayReceiver, IOptions dConfig) { @@ -23,6 +27,30 @@ public AirPlayService(IAirPlayReceiver airPlayReceiver, IOptions dCo _dConfig = dConfig?.Value ?? throw new ArgumentNullException(nameof(dConfig)); } + private void RecreateAudioOutput() + { + lock (_audioOutputLock) + { + Console.WriteLine("Recreating audio output after unexpected stop..."); + + try + { + _audioOutput?.Dispose(); + Thread.Sleep(200); + + _audioOutput = new AudioOutputService(); + _audioOutput.Initialize(); + _audioOutput.PlaybackStoppedUnexpectedly += (s, e) => RecreateAudioOutput(); + + Console.WriteLine("Audio output recreated successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to recreate audio output: {ex.Message}"); + } + } + } + public async Task StartAsync(CancellationToken cancellationToken) { #if DUMP @@ -49,6 +77,23 @@ public async Task StartAsync(CancellationToken cancellationToken) } #endif + // Initialize audio output on Windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + _audioOutput = new AudioOutputService(); + _audioOutput.Initialize(); + _audioOutput.PlaybackStoppedUnexpectedly += (s, e) => RecreateAudioOutput(); + Console.WriteLine("Audio output initialized successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to initialize audio output: {ex.Message}"); + Console.WriteLine("Continuing without audio output..."); + } + } + await _airPlayReceiver.StartListeners(cancellationToken); await _airPlayReceiver.StartMdnsAsync().ConfigureAwait(false); @@ -57,6 +102,15 @@ public async Task StartAsync(CancellationToken cancellationToken) // SET VOLUME }; + _airPlayReceiver.OnAudioFlushReceived += (s, e) => + { + Console.WriteLine("Audio flush received - restarting audio output for new track"); + lock (_audioOutputLock) + { + _audioOutput?.HandleFlush(); + } + }; + // DUMP H264 VIDEO _airPlayReceiver.OnH264DataReceived += (s, e) => { @@ -72,7 +126,12 @@ public async Task StartAsync(CancellationToken cancellationToken) _audiobuf = new List(); _airPlayReceiver.OnPCMDataReceived += (s, e) => { - // DO SOMETHING WITH AUDIO DATA.. + // Play audio through speakers + lock (_audioOutputLock) + { + _audioOutput?.AddSamples(e.Data, 0, e.Length); + } + #if DUMP _audiobuf.AddRange(e.Data); #endif @@ -81,6 +140,9 @@ public async Task StartAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken) { + _audioOutput?.Dispose(); + _audioOutput = null; + #if DUMP // DUMP WAV AUDIO var bPath = _dConfig.Path; @@ -100,6 +162,9 @@ public Task StopAsync(CancellationToken cancellationToken) public void Dispose() { + _audioOutput?.Dispose(); + _audioOutput = null; + if (_airPlayReceiver is IDisposable disposable) { disposable.Dispose(); diff --git a/AirPlay/IAirPlayReceiver.cs b/AirPlay/IAirPlayReceiver.cs index 3848e63..9f72683 100644 --- a/AirPlay/IAirPlayReceiver.cs +++ b/AirPlay/IAirPlayReceiver.cs @@ -10,6 +10,7 @@ public interface IAirPlayReceiver event EventHandler OnSetVolumeReceived; event EventHandler OnH264DataReceived; event EventHandler OnPCMDataReceived; + event EventHandler OnAudioFlushReceived; Task StartListeners(CancellationToken cancellationToken); diff --git a/AirPlay/IRtspReceiver.cs b/AirPlay/IRtspReceiver.cs index 4370da1..8097841 100644 --- a/AirPlay/IRtspReceiver.cs +++ b/AirPlay/IRtspReceiver.cs @@ -10,5 +10,6 @@ public interface IRtspReceiver void OnSetVolume(decimal volume); void OnData(H264Data data); void OnPCMData(PcmData data); + void OnAudioFlush(); } } diff --git a/AirPlay/Listeners/AudioListener.cs b/AirPlay/Listeners/AudioListener.cs index 7107cc0..8c4c85a 100644 --- a/AirPlay/Listeners/AudioListener.cs +++ b/AirPlay/Listeners/AudioListener.cs @@ -173,7 +173,7 @@ public override async Task OnRawDSocketAsync(Socket dSocket, CancellationToken c while ((audiobuf = RaopBufferDequeue(_raopBuffer, ref audiobuflen, ref timestamp, no_resend)) != null) { var pcmData = new PcmData(); - pcmData.Length = 960; + pcmData.Length = audiobuflen; pcmData.Data = audiobuf; pcmData.Pts = (ulong)(timestamp - _sync_timestamp) * 1000000UL / 44100 + _sync_time; @@ -198,6 +198,7 @@ public override async Task OnRawDSocketAsync(Socket dSocket, CancellationToken c public Task FlushAsync(int nextSequence) { RaopBufferFlush(_raopBuffer, nextSequence); + _receiver.OnAudioFlush(); return Task.CompletedTask; } diff --git a/AirPlay/Services/AudioOutputService.cs b/AirPlay/Services/AudioOutputService.cs new file mode 100644 index 0000000..bf5b700 --- /dev/null +++ b/AirPlay/Services/AudioOutputService.cs @@ -0,0 +1,239 @@ +using NAudio.Wave; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AirPlay.Services +{ + /// + /// Audio output service using NAudio for Windows audio playback. + /// Buffers decoded PCM data and plays through the default audio device. + /// + public class AudioOutputService : IDisposable + { + private IWavePlayer _waveOut; + private StreamingWaveProvider _streamProvider; + private readonly object _lock = new object(); + private bool _disposed = false; + private int _sampleCount = 0; + private bool _isPlaying = false; + private bool _initialized = false; + private int _prebufferPackets; + private WaveFormat _waveFormat; + private bool _needsReinit = false; + + public event EventHandler PlaybackStoppedUnexpectedly; + + public void Initialize() + { + lock (_lock) + { + if (_disposed) return; + + Console.WriteLine("Initializing audio output..."); + + // PCM format: 16-bit, 44.1kHz, stereo (as decoded by AAC-ELD/AAC/ALAC) + _waveFormat = new WaveFormat(44100, 16, 2); + + // Create streaming provider that NAudio pulls from + _streamProvider = new StreamingWaveProvider(_waveFormat, maxQueueSize: 500); + + // Create DirectSound device (but DON'T call Init() yet) + _waveOut = new DirectSoundOut(100); // 100ms latency + + _waveOut.PlaybackStopped += OnPlaybackStopped; + + // Don't call Init() until we have audio data in the queue. + // If Init() is called on an empty queue, DirectSound's render thread starts, + // gets silence, and may terminate. + _initialized = false; + + // Prebuffer 50 packets (~1 second of audio) before calling Init() + _prebufferPackets = 50; + + Console.WriteLine($"Audio device created: {_waveFormat.SampleRate}Hz, {_waveFormat.BitsPerSample}-bit, {_waveFormat.Channels} channels"); + Console.WriteLine($" Will call Init() after prebuffering {_prebufferPackets} packets"); + } + } + + private void OnPlaybackStopped(object sender, StoppedEventArgs args) + { + if (_disposed) return; + + if (args.Exception != null) + { + Console.WriteLine($"Playback stopped with error: {args.Exception.Message}"); + + lock (_lock) + { + _isPlaying = false; + _needsReinit = true; + } + + PlaybackStoppedUnexpectedly?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Handle a track change (FLUSH event). Recreates the DirectSound device + /// for a clean state. + /// + public void HandleFlush() + { + lock (_lock) + { + if (_disposed) return; + + Console.WriteLine("Track change detected - restarting audio output..."); + + try + { + if (_waveOut != null) + { + try + { + _waveOut.Stop(); + _waveOut.Dispose(); + } + catch { } + _waveOut = null; + } + + _streamProvider?.Clear(); + + _initialized = false; + _isPlaying = false; + _sampleCount = 0; + } + catch (Exception ex) + { + Console.WriteLine($"Error during flush cleanup: {ex.Message}"); + } + } + + // Wait outside the lock for COM cleanup + Thread.Sleep(200); + + lock (_lock) + { + try + { + _waveOut = new DirectSoundOut(100); + _waveOut.PlaybackStopped += OnPlaybackStopped; + Console.WriteLine($"Audio device recreated for new track, waiting for {_prebufferPackets} packets before Init()..."); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to recreate audio device: {ex.Message}"); + _needsReinit = true; + } + } + } + + /// + /// Add decoded PCM samples to the audio output queue. + /// + public void AddSamples(byte[] buffer, int offset, int count) + { + lock (_lock) + { + if (_disposed || _needsReinit) return; + if (_waveOut == null || _streamProvider == null) return; + + try + { + if (buffer == null || count <= 0) return; + if (offset < 0 || offset + count > buffer.Length) return; + + _streamProvider.AddSamples(buffer, offset, count); + _sampleCount++; + + // Initialize DirectSound AFTER prebuffering + if (!_initialized && _sampleCount >= _prebufferPackets) + { + Console.WriteLine($"Prebuffered {_streamProvider.QueuedBuffers} packets, calling Init()..."); + _waveOut.Init(_streamProvider); + _initialized = true; + } + + // Start playback after Init + if (_initialized && !_isPlaying) + { + _waveOut.Play(); + _isPlaying = true; + Console.WriteLine($"Audio playback started ({_streamProvider.QueuedBuffers} packets buffered)"); + } + + // Log status periodically + if (_sampleCount % 50000 == 0) + { + var state = _waveOut.PlaybackState; + var queued = _streamProvider.QueuedBuffers; + Console.WriteLine($"Audio: {_sampleCount} packets | Queue: {queued} | State: {state}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Audio error: {ex.Message}"); + } + } + } + + /// + /// Recreate the audio output after an error. + /// + public void Recreate() + { + lock (_lock) + { + Console.WriteLine("Recreating audio output..."); + + try + { + _waveOut?.Dispose(); + } + catch { } + + Thread.Sleep(200); + + try + { + _streamProvider?.Clear(); + _waveOut = new DirectSoundOut(100); + _waveOut.PlaybackStopped += OnPlaybackStopped; + _initialized = false; + _isPlaying = false; + _sampleCount = 0; + _needsReinit = false; + Console.WriteLine("Audio output recreated successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to recreate audio output: {ex.Message}"); + } + } + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + try + { + _waveOut?.Stop(); + _waveOut?.Dispose(); + } + catch { } + + _streamProvider?.Clear(); + _waveOut = null; + _streamProvider = null; + + Console.WriteLine($"Audio disposed (played {_sampleCount} packets total)"); + } + } + } +} diff --git a/AirPlay/Services/StreamingWaveProvider.cs b/AirPlay/Services/StreamingWaveProvider.cs new file mode 100644 index 0000000..4dc3d7c --- /dev/null +++ b/AirPlay/Services/StreamingWaveProvider.cs @@ -0,0 +1,100 @@ +using NAudio.Wave; +using System; +using System.Collections.Concurrent; + +namespace AirPlay.Services +{ + /// + /// A thread-safe IWaveProvider that buffers PCM audio data in a queue. + /// NAudio's output device pulls data from this provider via Read(). + /// + public class StreamingWaveProvider : IWaveProvider + { + private readonly ConcurrentQueue _queue = new ConcurrentQueue(); + private readonly int _maxQueueSize; + private byte[] _currentBuffer; + private int _currentOffset; + + public WaveFormat WaveFormat { get; } + public int QueuedBuffers => _queue.Count; + public long TotalBytesRead { get; private set; } + public DateTime LastReadTime { get; private set; } = DateTime.UtcNow; + + public StreamingWaveProvider(WaveFormat waveFormat, int maxQueueSize = 500) + { + WaveFormat = waveFormat; + _maxQueueSize = maxQueueSize; + } + + /// + /// Add PCM samples to the streaming queue. + /// Returns false if the queue is full and the sample was dropped. + /// + public bool AddSamples(byte[] buffer, int offset, int count) + { + if (buffer == null || count <= 0) + return false; + + if (_queue.Count >= _maxQueueSize) + return false; + + var copy = new byte[count]; + Array.Copy(buffer, offset, copy, 0, count); + _queue.Enqueue(copy); + return true; + } + + /// + /// Clear the buffer queue (e.g., on track change). + /// + public void Clear() + { + while (_queue.TryDequeue(out _)) { } + _currentBuffer = null; + _currentOffset = 0; + } + + /// + /// Called by NAudio's output device to pull audio data. + /// Returns silence (zeros) if no data is available. + /// + public int Read(byte[] buffer, int offset, int count) + { + LastReadTime = DateTime.UtcNow; + int bytesWritten = 0; + + while (bytesWritten < count) + { + // If we have a partial buffer from previous read, use it + if (_currentBuffer != null && _currentOffset < _currentBuffer.Length) + { + int available = _currentBuffer.Length - _currentOffset; + int toCopy = Math.Min(available, count - bytesWritten); + Array.Copy(_currentBuffer, _currentOffset, buffer, offset + bytesWritten, toCopy); + _currentOffset += toCopy; + bytesWritten += toCopy; + + if (_currentOffset >= _currentBuffer.Length) + { + _currentBuffer = null; + _currentOffset = 0; + } + } + else if (_queue.TryDequeue(out var next)) + { + _currentBuffer = next; + _currentOffset = 0; + } + else + { + // No more data — fill remaining with silence + Array.Clear(buffer, offset + bytesWritten, count - bytesWritten); + bytesWritten = count; + } + } + + TotalBytesRead += bytesWritten; + return bytesWritten; + } + } +} From bcec5900e4e3283a21fa1fea0711a37d5c087c96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:10:03 +0000 Subject: [PATCH 08/10] Extract magic numbers to named constants in AudioOutputService Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- AirPlay/Services/AudioOutputService.cs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/AirPlay/Services/AudioOutputService.cs b/AirPlay/Services/AudioOutputService.cs index bf5b700..9dcd935 100644 --- a/AirPlay/Services/AudioOutputService.cs +++ b/AirPlay/Services/AudioOutputService.cs @@ -11,6 +11,10 @@ namespace AirPlay.Services /// public class AudioOutputService : IDisposable { + private const int COM_CLEANUP_DELAY_MS = 200; + private const int PREBUFFER_PACKETS = 50; + private const int LOG_FREQUENCY_PACKETS = 50000; + private IWavePlayer _waveOut; private StreamingWaveProvider _streamProvider; private readonly object _lock = new object(); @@ -18,7 +22,6 @@ public class AudioOutputService : IDisposable private int _sampleCount = 0; private bool _isPlaying = false; private bool _initialized = false; - private int _prebufferPackets; private WaveFormat _waveFormat; private bool _needsReinit = false; @@ -48,11 +51,8 @@ public void Initialize() // gets silence, and may terminate. _initialized = false; - // Prebuffer 50 packets (~1 second of audio) before calling Init() - _prebufferPackets = 50; - Console.WriteLine($"Audio device created: {_waveFormat.SampleRate}Hz, {_waveFormat.BitsPerSample}-bit, {_waveFormat.Channels} channels"); - Console.WriteLine($" Will call Init() after prebuffering {_prebufferPackets} packets"); + Console.WriteLine($" Will call Init() after prebuffering {PREBUFFER_PACKETS} packets"); } } @@ -112,7 +112,7 @@ public void HandleFlush() } // Wait outside the lock for COM cleanup - Thread.Sleep(200); + Thread.Sleep(COM_CLEANUP_DELAY_MS); lock (_lock) { @@ -120,7 +120,7 @@ public void HandleFlush() { _waveOut = new DirectSoundOut(100); _waveOut.PlaybackStopped += OnPlaybackStopped; - Console.WriteLine($"Audio device recreated for new track, waiting for {_prebufferPackets} packets before Init()..."); + Console.WriteLine($"Audio device recreated for new track, waiting for {PREBUFFER_PACKETS} packets before Init()..."); } catch (Exception ex) { @@ -149,7 +149,7 @@ public void AddSamples(byte[] buffer, int offset, int count) _sampleCount++; // Initialize DirectSound AFTER prebuffering - if (!_initialized && _sampleCount >= _prebufferPackets) + if (!_initialized && _sampleCount >= PREBUFFER_PACKETS) { Console.WriteLine($"Prebuffered {_streamProvider.QueuedBuffers} packets, calling Init()..."); _waveOut.Init(_streamProvider); @@ -165,7 +165,7 @@ public void AddSamples(byte[] buffer, int offset, int count) } // Log status periodically - if (_sampleCount % 50000 == 0) + if (_sampleCount % LOG_FREQUENCY_PACKETS == 0) { var state = _waveOut.PlaybackState; var queued = _streamProvider.QueuedBuffers; @@ -194,7 +194,7 @@ public void Recreate() } catch { } - Thread.Sleep(200); + Thread.Sleep(COM_CLEANUP_DELAY_MS); try { From 51f04901cc4016d229651554ca859098f3346b50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:26:51 +0000 Subject: [PATCH 09/10] Fix infinite dequeue loop causing audio flooding and silence - Fix RaopBufferDequeue: return null instead of silent buffer when entry not available (was causing infinite loop without advancing FirstSeqNum) - Fix RaopBufferDequeue: advance FirstSeqNum on buffer overrun skip - Add dequeue rate limiting: max 10 frames per received packet - Skip empty/zero-length decoded frames in dequeue loop Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- AirPlay/Listeners/AudioListener.cs | 35 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/AirPlay/Listeners/AudioListener.cs b/AirPlay/Listeners/AudioListener.cs index 8c4c85a..e7da7a1 100644 --- a/AirPlay/Listeners/AudioListener.cs +++ b/AirPlay/Listeners/AudioListener.cs @@ -169,9 +169,15 @@ public override async Task OnRawDSocketAsync(Socket dSocket, CancellationToken c //if(_raopBuffer.LastSeqNum - _raopBuffer.FirstSeqNum > (RAOP_BUFFER_LENGTH / 8)) //{ - // Dequeue all frames in queue - while ((audiobuf = RaopBufferDequeue(_raopBuffer, ref audiobuflen, ref timestamp, no_resend)) != null) + // Dequeue frames from buffer (limit per packet to prevent flooding) + int dequeueCount = 0; + const int maxDequeuePerPacket = 10; + while (dequeueCount < maxDequeuePerPacket && + (audiobuf = RaopBufferDequeue(_raopBuffer, ref audiobuflen, ref timestamp, no_resend)) != null) { + if (audiobuf.Length == 0 || audiobuflen <= 0) + continue; + var pcmData = new PcmData(); pcmData.Length = audiobuflen; pcmData.Data = audiobuf; @@ -179,6 +185,7 @@ public override async Task OnRawDSocketAsync(Socket dSocket, CancellationToken c pcmData.Pts = (ulong)(timestamp - _sync_timestamp) * 1000000UL / 44100 + _sync_time; _receiver.OnPCMData(pcmData); + dequeueCount++; } //} @@ -373,25 +380,17 @@ public byte[] RaopBufferDequeue(RaopBuffer raop_buffer, ref int length, ref uint /* Check how much we have space left in the buffer */ if (buflen < RAOP_BUFFER_LENGTH) { - /* Return nothing and hope resend gets on time */ - length = entry.AudioBufferSize; - Array.Fill(entry.AudioBuffer, 0, 0, length); - - return entry.AudioBuffer; + /* Entry not yet available, wait for resend - return null to stop dequeue loop */ + return null; } - /* Risk of buffer overrun, return empty buffer */ - return Array.Empty(); - } - /* Update buffer and validate entry */ - if (!entry.Available) - { - /* Return an empty audio buffer to skip audio */ - length = entry.AudioBufferSize; - Array.Fill(entry.AudioBuffer, 0, 0, length); - - return entry.AudioBuffer; + /* Risk of buffer overrun, skip this entry and advance */ + entry.AudioBufferLen = 0; + raop_buffer.Entries[raop_buffer.FirstSeqNum % RAOP_BUFFER_LENGTH] = entry; + raop_buffer.FirstSeqNum += 1; + return null; } + entry.Available = false; /* Return entry audio buffer */ From f1f3697e5e90b9ea9f539ab7b3877bbb2f7a399b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:29:09 +0000 Subject: [PATCH 10/10] Address code review: move dequeueCount increment before skip, add comments Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com> --- AirPlay/Listeners/AudioListener.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AirPlay/Listeners/AudioListener.cs b/AirPlay/Listeners/AudioListener.cs index e7da7a1..6eb8e78 100644 --- a/AirPlay/Listeners/AudioListener.cs +++ b/AirPlay/Listeners/AudioListener.cs @@ -169,12 +169,14 @@ public override async Task OnRawDSocketAsync(Socket dSocket, CancellationToken c //if(_raopBuffer.LastSeqNum - _raopBuffer.FirstSeqNum > (RAOP_BUFFER_LENGTH / 8)) //{ - // Dequeue frames from buffer (limit per packet to prevent flooding) + // Dequeue frames from buffer, limit to prevent flooding the output queue int dequeueCount = 0; const int maxDequeuePerPacket = 10; while (dequeueCount < maxDequeuePerPacket && (audiobuf = RaopBufferDequeue(_raopBuffer, ref audiobuflen, ref timestamp, no_resend)) != null) { + dequeueCount++; + if (audiobuf.Length == 0 || audiobuflen <= 0) continue; @@ -185,7 +187,6 @@ public override async Task OnRawDSocketAsync(Socket dSocket, CancellationToken c pcmData.Pts = (ulong)(timestamp - _sync_timestamp) * 1000000UL / 44100 + _sync_time; _receiver.OnPCMData(pcmData); - dequeueCount++; } //} @@ -384,7 +385,7 @@ public byte[] RaopBufferDequeue(RaopBuffer raop_buffer, ref int length, ref uint return null; } - /* Risk of buffer overrun, skip this entry and advance */ + /* Risk of buffer overrun, skip this entry and advance to prevent getting stuck */ entry.AudioBufferLen = 0; raop_buffer.Entries[raop_buffer.FirstSeqNum % RAOP_BUFFER_LENGTH] = entry; raop_buffer.FirstSeqNum += 1;