Fix screen mirroring audio, video freeze on reconnect, volume control, and improve lossless codec support - #4
Conversation
…ix buffer init bug, fix NTP sync, optimize dequeue Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…ialization Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…ngth, add VideoOutputService Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…ll exception in logs Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…ledException - Stop old AudioListener before creating new one (release UDP ports 7002/7003) - Null out session listener references in TEARDOWN for proper reconnect - Fix SessionManager to allow intentional null/reset of session fields - Handle TaskCanceledException in MirroringListener gracefully - Reset AES-CTR cipher state on new mirroring connection - Reset video state (SpsPps, StreamConnectionId, MirroringSession) on teardown Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
… document SessionManager Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
… iOS volume control - Audio during screen mirroring arrives as type 0x56 packets on the control socket but was only queued and never dequeued/played. Add dequeue loop after queue. - Initialize AAC-ELD decoder in OnRawCSocketAsync (was only initialized in data socket handler) - Add SetVolume method to AudioOutputService with AirPlay dB-to-linear conversion - Wire up OnSetVolumeReceived event to AudioOutputService.SetVolume Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
Root cause: SharpJaad.AAC throws "unsupported profile: ER_AAC_LD" for AAC-ELD, causing the decoder init to fail and audio socket handler to die silently. Solution: Use fdk-aac native library (libAACdec.dll) which natively supports AAC-ELD. Volume fix: DirectSoundOut.Volume throws InvalidOperationException. Apply volume via StreamingWaveProvider PCM sample scaling instead. Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…ASC format Error 8204 (0x200C) = AAC_DEC_OUTPUT_BUFFER_TOO_SMALL. The FDK AAC library requires an output buffer of at least 2048 * channels samples for internal processing, but only 480 * channels = 960 samples were allocated. Also updated the AudioSpecificConfig to match the format used by the original airplayreceiver project (which is known to work with FDK AAC). Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…ix PCM decoder - Advertise cn=0,1,2,3 (PCM, ALAC, AAC, AAC-ELD) instead of cn=2,3 - Parse ct (compression type) and spf (samples per frame) from SETUP request - Use spf from client to configure decoder frame length dynamically - Fix PCMDecoder.GetOutputStreamLength() returning -1 (could crash buffer allocation) - Add PCM format description to GetAudioFormatDescription - Increase RaopBuffer audio_buffer_size to 1024*4 for AAC-main support - Add fallback decoder selection from ct when audioFormat is unknown Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…s (ct, spf, audioFormat) Binary plist parser returns Int16 for small values like ct=8, but (int) unboxing cast fails on boxed Int16. Use Convert.ToInt32() which handles all numeric types. Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
The fdk-aac NuGet package returns error 0x5 (AAC_DEC_UNSUPPORTED_ER_FORMAT) because the pre-built binary doesn't include Error Resilient format support which AAC-ELD requires. Solution: Use FFmpeg as a subprocess (piping ADTS-wrapped AAC frames through stdin and reading PCM S16LE from stdout). This works with any FFmpeg version and is the approach UxPlay/shairport-sync use (via GStreamer/libavcodec). Also removed the fdk-aac and FFmpeg.AutoGen NuGet packages as they're no longer needed. Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
…eedback - Rename class and file to accurately reflect FFmpeg subprocess implementation - Extract magic numbers to named constants (MAX_READ_ATTEMPTS, PROCESS_EXIT_TIMEOUT_MS) - Implement graceful shutdown: close stdin first, wait for exit, then force kill Co-authored-by: YimingZhanshen <76594627+YimingZhanshen@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses multiple AirPlay runtime issues (mirroring audio/video reliability, volume control stability, and codec advertisement/handling), and adds a video output path for external players via named pipes/FIFO.
Changes:
- Add FFmpeg-subprocess AAC-ELD decoding and expand advertised/parsed audio codec parameters (cn/ct/spf) to improve compatibility (incl. lossless).
- Fix mirroring reconnect/cipher reset and H.264 parsing/timestamp handling to prevent freezes and improve frame processing.
- Implement safe volume control by scaling PCM samples in the wave provider, and add a video output service that writes Annex B H.264 to a pipe/FIFO.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 22 comments.
Show a summary per file
| File | Description |
|---|---|
| AirPlay/Services/VideoOutputService.cs | New named pipe/FIFO video writer for mirrored H.264 output. |
| AirPlay/Services/StreamingWaveProvider.cs | Add per-sample PCM volume scaling in the audio read path. |
| AirPlay/Services/SessionManager.cs | Change session updates to replace the stored session object (supports intentional nulling). |
| AirPlay/Services/AudioOutputService.cs | Reduce latency/buffer sizes and route volume changes into StreamingWaveProvider. |
| AirPlay/Models/Session.cs | Add ct/spf session fields to support dynamic decoder configuration. |
| AirPlay/Models/Mirroring/MirroringHeader.cs | Fix NTP fractional conversion to PTS. |
| AirPlay/Listeners/MirroringListener.cs | Reset cipher state on reconnect, improve header/payload reading, and fix NALU/PPS parsing. |
| AirPlay/Listeners/AudioListener.cs | Initialize decoder for mirroring audio on control socket; adjust buffering/dequeueing and decoder selection. |
| AirPlay/Listeners/AirTunesListener.cs | Parse ct/spf and stop/recreate listeners cleanly on reconnect/teardown. |
| AirPlay/Decoders/Implementations/PCMDecoder.cs | Make PCM output sizing/configurable and avoid over-copying. |
| AirPlay/Decoders/Implementations/FFmpegAacEldDecoder.cs | New FFmpeg-based AAC-ELD decoder via stdin/stdout pipes. |
| AirPlay/AirPlayService.cs | Wire volume events and forward received H.264 frames to VideoOutputService. |
| AirPlay/AirPlayReceiver.cs | Advertise additional codecs via RAOP cn=0,1,2,3 (PCM/ALAC/AAC/AAC-ELD). |
Comments suppressed due to low confidence (31)
AirPlay/Listeners/AirTunesListener.cs:353
stream["controlPort"]is unboxed asshort. The BinaryPlistReader can returnshortorintdepending on the encoded integer size; unboxing toshortwill throw if the value is boxed asint. UseConvert.ToUInt16/ToInt32(consistent with theaudioFormat/ct/spfchanges) to avoid InvalidCastException.
{
session.AudioSamplesPerFrame = Convert.ToInt32(stream["spf"]);
}
if (stream.ContainsKey("controlPort"))
{
// Use this port to request resend lost packet? (remote port)
var controlPort = (ushort)((short)stream["controlPort"]);
AirPlay/AirPlayReceiver.cs:67
- Disposable 'ServiceDiscovery' is created but not disposed.
var sd = new ServiceDiscovery(_mdns);
AirPlay/Listeners/AudioListener.cs:452
- This comparison is always false.
for (seqnum = raop_buffer.FirstSeqNum; Utilities.SeqNumCmp(seqnum, raop_buffer.LastSeqNum) < 0; seqnum++)
AirPlay/Listeners/AirTunesListener.cs:538
- Empty block without comment.
{
}
AirPlay/Services/AudioOutputService.cs:99
- Poor error handling: empty catch block.
catch { }
AirPlay/Services/AudioOutputService.cs:229
- Poor error handling: empty catch block.
catch { }
AirPlay/Services/AudioOutputService.cs:263
- Poor error handling: empty catch block.
catch { }
AirPlay/Listeners/AudioListener.cs:448
- Condition always evaluates to 'false'.
if (Utilities.SeqNumCmp(raop_buffer.FirstSeqNum, raop_buffer.LastSeqNum) < 0)
AirPlay/Listeners/AirTunesListener.cs:538
- If-statement with an empty then-branch and no else-branch.
if(request.Type == RequestType.ANNOUNCE)
{
}
AirPlay/Listeners/AirTunesListener.cs:488
- Inefficient use of 'ContainsKey' and indexer.
if (request.Headers.ContainsKey("Content-Type"))
AirPlay/Listeners/AirTunesListener.cs:543
- Inefficient use of 'ContainsKey' and indexer.
if (request.Headers.ContainsKey("RTP-Info"))
AirPlay/Listeners/AudioListener.cs:174
- This assignment to type_d is useless, since its value is never read.
int type_d = packet[1] & ~0x80;
AirPlay/Listeners/AudioListener.cs:186
- This assignment to buf_ret is useless, since its value is never read.
buf_ret = RaopBufferQueue(_raopBuffer, packet, (ushort)dret, session);
AirPlay/Listeners/AirTunesListener.cs:201
- This assignment to signatureBuffer is useless, since its value is never read.
signatureBuffer = aesCtr128Encrypt.ProcessBytes(signatureBuffer);
AirPlay/Listeners/AirTunesListener.cs:227
- This assignment to mode is useless, since its value is never read.
var mode = body[14];
AirPlay/Listeners/AirTunesListener.cs:353
- This assignment to controlPort is useless, since its value is never read.
var controlPort = (ushort)((short)stream["controlPort"]);
AirPlay/Listeners/AirTunesListener.cs:412
- This assignment to timingPort is useless, since its value is never read.
var timingPort = (ushort)((short)plist["timingPort"]);
AirPlay/Listeners/AirTunesListener.cs:510
- This assignment to start is useless, since its value is never read.
var start = long.Parse(pVals[0]);
AirPlay/Listeners/AirTunesListener.cs:511
- This assignment to current is useless, since its value is never read.
var current = long.Parse(pVals[1]);
AirPlay/Listeners/AirTunesListener.cs:512
- This assignment to end is useless, since its value is never read.
var end = long.Parse(pVals[2]);
AirPlay/Listeners/AirTunesListener.cs:520
- This assignment to image is useless, since its value is never read.
var image = request.Body;
AirPlay/Listeners/AirTunesListener.cs:527
- This assignment to output is useless, since its value is never read.
var output = dmap.Decode(request.Body);
AirPlay/Listeners/AirTunesListener.cs:530
- These 'if' statements can be combined.
if (request.Type == RequestType.SET_PARAMETER)
{
if (request.Headers.ContainsKey("Content-Type"))
{
var contentType = request.Headers["Content-Type"];
if (contentType.Equals("text/parameters", StringComparison.OrdinalIgnoreCase))
{
var body = Encoding.ASCII.GetString(request.Body);
var keyPair = body.Split(":", StringSplitOptions.RemoveEmptyEntries).Select(b => b.Trim(' ', '\r', '\n')).ToArray();
if(keyPair.Length == 2)
{
var key = keyPair[0];
var val = keyPair[1];
if (key.Equals("volume", StringComparison.OrdinalIgnoreCase))
{
// request.Body contains 'volume: N.NNNNNN'
_receiver.OnSetVolume(decimal.Parse(val, CultureInfo.InvariantCulture));
}
else if (key.Equals("progress", StringComparison.OrdinalIgnoreCase))
{
var pVals = val.Split("/", StringSplitOptions.RemoveEmptyEntries);
var start = long.Parse(pVals[0]);
var current = long.Parse(pVals[1]);
var end = long.Parse(pVals[2]);
// DO SOMETHING W/ PROGRESS
}
}
}
else if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
{
var image = request.Body;
// DO SOMETHING W/ IMAGE
}
else if (contentType.Equals("application/x-dmap-tagged", StringComparison.OrdinalIgnoreCase))
{
var dmap = new DMapTagged();
var output = dmap.Decode(request.Body);
}
}
}
AirPlay/Models/Session.cs:48
- The expression 'A ? B : false' can be simplified to 'A && B'.
public bool MirroringSessionReady => StreamConnectionId != null && MirroringSession.HasValue ? MirroringSession.Value : false;
AirPlay/AirPlayReceiver.cs:27
- Field '_airTunesListener' can be 'readonly'.
private AirTunesListener _airTunesListener = null;
AirPlay/Listeners/AudioListener.cs:26
- Field '_aesCbcDecrypt' can be 'readonly'.
private IBufferedCipher _aesCbcDecrypt;
AirPlay/Listeners/AudioListener.cs:32
- Field '_controlSequenceNumber' can be 'readonly'.
private ushort _controlSequenceNumber = 0;
AirPlay/Listeners/AudioListener.cs:33
- Field '_raopBuffer' can be 'readonly'.
private RaopBuffer _raopBuffer;
AirPlay/Listeners/MirroringListener.cs:25
- Field '_og' can be 'readonly'.
private byte[] _og = new byte[16];
AirPlay/Models/Session.cs:9
- Field '_sessionId' can be 'readonly'.
private string _sessionId;
AirPlay/Services/SessionManager.cs:12
- Field '_sessions' can be 'readonly'.
private ConcurrentDictionary<string, Session> _sessions;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ((headerBuffer[0] == 80 && headerBuffer[1] == 79 && headerBuffer[2] == 83 && headerBuffer[3] == 84) || (headerBuffer[0] == 71 && headerBuffer[1] == 69 && headerBuffer[2] == 84)) | ||
| { | ||
| // Request is POST or GET (skip) | ||
| continue; | ||
| } |
There was a problem hiding this comment.
The POST/GET detection continues after reading only the first 4 bytes, leaving the rest of the HTTP request in the stream. On the next iteration those bytes will be misinterpreted as a mirroring header, which can corrupt parsing and potentially lead to huge PayloadSize allocations. Consume and discard the entire HTTP request (e.g., read until \r\n\r\n) before continuing, or handle HTTP requests on a separate path before attempting to parse 128-byte mirroring headers.
| if (nc_len <= 0 || offset + 4 + nc_len > payload.Length) | ||
| { | ||
| return; | ||
| break; |
There was a problem hiding this comment.
In the AVCC->AnnexB conversion, when an invalid NALU length is encountered you break the loop but still emit the (partially converted) payload. That can produce an invalid H.264 stream. Prefer returning (drop the frame) when nc_len is invalid/out-of-range rather than sending partially converted data.
| break; | |
| // Invalid NALU length: drop this frame instead of emitting partially converted data | |
| return; |
| else if (audioFormat == AudioFormat.PCM) | ||
| { | ||
| // (PCM -> PCM) | ||
| // Not used | ||
| // Raw PCM audio - no decoding needed | ||
| _decoder = new PCMDecoder(); | ||
| } |
There was a problem hiding this comment.
PCMDecoder is instantiated but never configured. PCMDecoder.GetOutputStreamLength() depends on Config() to set _pcmSize; otherwise the default size is used and DecodeFrame may truncate PCM frames that are larger than the default. Call Config() here using the negotiated spf (or another correct frame size) so output sizing is correct.
| private void WaitForUnixPipeConnection(CancellationToken token) | ||
| { | ||
| try | ||
| { | ||
| // This will block until a reader connects | ||
| _unixPipeStream = new FileStream(UNIX_PIPE_PATH, FileMode.Open, FileAccess.Write); | ||
| lock (_lock) |
There was a problem hiding this comment.
WaitForUnixPipeConnection blocks on new FileStream(..., FileAccess.Write) until a reader connects and does not honor the cancellation token. This can leave the accept task stuck during shutdown/dispose. Consider opening the FIFO in non-blocking mode (or open ReadWrite to avoid the writer-open block) and/or use a cancellation-aware approach so disposal can complete reliably.
| try | ||
| { | ||
| byte[] payload = new byte[header.PayloadSize]; | ||
|
|
There was a problem hiding this comment.
header.PayloadSize is used directly to allocate a payload buffer with no upper bound. If the stream is desynced or malformed (including due to the POST/GET handling above), this can cause excessive allocations / OOM. Add a reasonable maximum payload size check and close/skip when exceeded.
| @@ -87,9 +90,22 @@ public override async Task OnRawCSocketAsync(Socket cSocket, CancellationToken c | |||
| var data = reader.ReadBytes(cret - 4); | |||
|
|
|||
| var ret = RaopBufferQueue(_raopBuffer, data, (ushort)data.Length, session); | |||
There was a problem hiding this comment.
This assignment to ret is useless, since its value is never read.
| var ret = RaopBufferQueue(_raopBuffer, data, (ushort)data.Length, session); | |
| RaopBufferQueue(_raopBuffer, data, (ushort)data.Length, session); |
| uint ntp_fraction = (uint)reader.ReadInt32(); | ||
| ulong ntp_time = ((ulong)ntp_seconds * 1000000UL) + (((ulong)ntp_fraction * 1000000UL) >> 32); | ||
| uint rtp_timestamp = (uint)((packet[4] << 24) | (packet[5] << 16) | (packet[6] << 8) | packet[7]); | ||
| uint next_timestamp = (uint)((packet[16] << 24) | (packet[17] << 16) | (packet[18] << 8) | packet[19]); |
There was a problem hiding this comment.
This assignment to next_timestamp is useless, since its value is never read.
| uint next_timestamp = (uint)((packet[16] << 24) | (packet[17] << 16) | (packet[18] << 8) | packet[19]); |
| if (_ffmpegProcess != null && !_ffmpegProcess.HasExited) | ||
| { | ||
| // Wait for graceful exit, then force kill if needed | ||
| if (!_ffmpegProcess.WaitForExit(PROCESS_EXIT_TIMEOUT_MS)) | ||
| { | ||
| _ffmpegProcess.Kill(); | ||
| _ffmpegProcess.WaitForExit(PROCESS_EXIT_TIMEOUT_MS); | ||
| } | ||
| } |
There was a problem hiding this comment.
These 'if' statements can be combined.
Screen mirroring had no audio (SharpJaad doesn't support AAC-ELD), video froze on reconnect (stale session/cipher state), volume control crashed (
DirectSoundOut.Volumeunsupported), and only AAC/AAC-ELD codecs were advertised (no lossless).AAC-ELD audio decoding
FFmpegAacEldDecoder: pipes ADTS-wrapped AAC-ELD frames through anffmpegsubprocess (stdin→ PCM S16LEstdout), matching the approach used by UxPlay/shairport-sync.fdk-aacandFFmpeg.AutoGenNuGet dependencies.Video/mirroring fixes
& 2040→& 0xFFfor correct high byte extraction._nextDecryptCount,_og). OldAudioListeneris stopped before recreating to release UDP ports 7002/7003.Int32.MaxValue→>> 32bit-shift for fractional seconds inMirroringHeader.Task.Delaycalls in mirroring loop.Volume control
DirectSoundOut.set_VolumethrowsInvalidOperationException. Volume is now applied by scaling PCM samples directly inStreamingWaveProvider.Read().Lossless codec support
cn=0,1,2,3(PCM, ALAC, AAC, AAC-ELD) instead ofcn=2,3.ct(compression type) andspf(samples per frame) from SETUP plist for dynamic decoder configuration.Convert.ToInt32()for plist integer values (parser returnsInt16for small values, direct(int)unbox fails).Audio latency (prior commit, included in PR)
RaopBufferInitslice bug:Skip(i)→Skip(i * audio_buffer_size).Array.Copy/Array.Clearin hot path.Video output
VideoOutputService: writes H.264 Annex B to a named pipe (\\.\pipe\AirPlayVideo) for consumption by ffplay/mpv/vlc.Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.