Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +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: [Debug, Release]

steps:
- name: Checkout code
Expand All @@ -30,10 +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: 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 Release --runtime win-x64 --self-contained true --output ./publish/win-x64

Comment on lines 11 to +33

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI workflow was changed from a multi-OS/multi-config matrix to Windows-only Release publish. If the project is intended to stay cross-platform, this removes build coverage for Linux/macOS and may allow regressions to slip in. Consider keeping matrix builds for compile/test on other OSes while still publishing the win-x64 artifact.

Copilot uses AI. Check for mistakes.
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: AirPlay-win-x64
path: ./publish/win-x64/
7 changes: 5 additions & 2 deletions AirPlay/AirPlay.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<NoWarn>$(NoWarn);SYSLIB0011</NoWarn>
<NoWarn>$(NoWarn);SYSLIB0011;SYSLIB0050</NoWarn>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>

<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
Expand All @@ -16,15 +17,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="curve25519-pcl" Version="1.0.1" />
<PackageReference Include="LibALAC" Version="1.0.7" />
<PackageReference Include="Makaretu.Dns.Multicast" Version="0.27.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="NAudio" Version="2.2.1" />
<PackageReference Include="RtspClientSharp" Version="1.3.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="Chaos.NaCl.Core" Version="1.0.0" />
<PackageReference Include="Curve25519" Version="1.0.1" />
<PackageReference Include="SharpJaad.AAC" Version="0.0.6" />
</ItemGroup>

<ItemGroup>
Expand Down
46 changes: 28 additions & 18 deletions AirPlay/AirPlayReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@

namespace AirPlay
{
public class AirPlayReceiver : IRtspReceiver, IAirPlayReceiver
public class AirPlayReceiver : IRtspReceiver, IAirPlayReceiver, IDisposable
{
public event EventHandler<decimal> OnSetVolumeReceived;
public event EventHandler<H264Data> OnH264DataReceived;
public event EventHandler<decimal> OnSetVolumeReceived;
public event EventHandler<H264Data> OnH264DataReceived;
public event EventHandler<PcmData> OnPCMDataReceived;
public event EventHandler OnAudioFlushReceived;

public const string AirPlayType = "_airplay._tcp";
public const string AirTunesType = "_raop._tcp";
Expand All @@ -29,17 +30,16 @@ public class AirPlayReceiver : IRtspReceiver, IAirPlayReceiver
private readonly ushort _airPlayPort;
private readonly string _deviceId;

public AirPlayReceiver(IOptions<AirPlayReceiverConfig> aprConfig, IOptions<CodecLibrariesConfig> codecConfig, IOptions<DumpConfig> 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<AirPlayReceiverConfig> aprConfig, IOptions<DumpConfig> 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)
Expand Down Expand Up @@ -137,9 +137,19 @@ 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 OnAudioFlush()
{
OnAudioFlushReceived?.Invoke(this, EventArgs.Empty);
}

public void Dispose()
{
_mdns?.Stop();
}
}
}
72 changes: 70 additions & 2 deletions AirPlay/AirPlayService.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,14 +17,40 @@ public class AirPlayService : IHostedService, IDisposable
private readonly IAirPlayReceiver _airPlayReceiver;
private readonly DumpConfig _dConfig;

private AudioOutputService _audioOutput;
private List<byte> _audiobuf;

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contents of this container are never accessed.

Suggested change
private List<byte> _audiobuf;

Copilot uses AI. Check for mistakes.
private readonly object _audioOutputLock = new object();

public AirPlayService(IAirPlayReceiver airPlayReceiver, IOptions<DumpConfig> dConfig)
{
_airPlayReceiver = airPlayReceiver ?? throw new ArgumentNullException(nameof(airPlayReceiver));
_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();

Comment on lines +32 to +44

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AirPlayService.RecreateAudioOutput() calls Thread.Sleep(200) while holding _audioOutputLock, which blocks audio writes (OnPCMDataReceived) and flush handling during the sleep. Release the lock before sleeping, or perform the delay after disposing but before reacquiring the lock to recreate.

Copilot uses AI. Check for mistakes.
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
Expand All @@ -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);

Expand All @@ -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) =>
{
Expand All @@ -72,7 +126,12 @@ public async Task StartAsync(CancellationToken cancellationToken)
_audiobuf = new List<byte>();
_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);

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In DUMP builds, _audiobuf.AddRange(e.Data) appends the entire backing array even when e.Length indicates only part of it is valid PCM. This can inflate dumps with trailing zeros/garbage and make the WAV header length inconsistent. Append only the first e.Length bytes.

Suggested change
_audiobuf.AddRange(e.Data);
var validPcmChunk = new byte[e.Length];
Buffer.BlockCopy(e.Data, 0, validPcmChunk, 0, e.Length);
_audiobuf.AddRange(validPcmChunk);

Copilot uses AI. Check for mistakes.
#endif
Expand All @@ -81,6 +140,9 @@ public async Task StartAsync(CancellationToken cancellationToken)

public Task StopAsync(CancellationToken cancellationToken)
{
_audioOutput?.Dispose();
_audioOutput = null;

Comment on lines 141 to +145

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AirPlayService.StopAsync() disposes and nulls _audioOutput without taking _audioOutputLock, so it can race with OnPCMDataReceived / OnAudioFlushReceived which also access _audioOutput under that lock. Take _audioOutputLock while disposing/nulling to ensure consistent synchronization.

Copilot uses AI. Check for mistakes.
#if DUMP
// DUMP WAV AUDIO
var bPath = _dConfig.Path;
Expand All @@ -100,7 +162,13 @@ public Task StopAsync(CancellationToken cancellationToken)

public void Dispose()
{
_audioOutput?.Dispose();
_audioOutput = null;

Comment on lines 163 to 167

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AirPlayService.Dispose() disposes and nulls _audioOutput without taking _audioOutputLock, which can race with event handlers that call _audioOutput under the lock. Use the same _audioOutputLock when disposing/nulling to avoid dispose-vs-use races.

Copilot uses AI. Check for mistakes.
if (_airPlayReceiver is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}
Loading
Loading