From 8b76eecc9f3e502fd0d9b4d72b9cfc1173bbbfaa Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:12:50 +0200 Subject: [PATCH 01/14] Enhance ReceiverInfo with PTPInfo support Added PTPInfo key handling and updated ReceiverInfo struct. --- internal/airplay/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/airplay/client.go b/internal/airplay/client.go index 89caa35..a358415 100644 --- a/internal/airplay/client.go +++ b/internal/airplay/client.go @@ -42,6 +42,7 @@ type ReceiverInfo struct { PI string `plist:"pi"` MacAddress string `plist:"macAddress"` Displays []DisplayInfo `plist:"displays"` + hasPTPInfo bool } // AirPlay receiver status flags used to choose one authentication prompt. @@ -281,7 +282,7 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) { } return keys }()) - for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats"} { + for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats", "PTPInfo"} { if v, ok := fullInfo[key]; ok { dbg("[INFO] %s: %+v", key, v) } @@ -292,6 +293,9 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) { if _, err := plist.Unmarshal(resp, &info); err != nil { return nil, fmt.Errorf("decode info plist: %w", err) } + if _, ok := fullInfo["PTPInfo"]; ok { + info.hasPTPInfo = true + } c.info = &info return &info, nil } From 0c1e5ed2c666f7c0352ee192875983c4e4252944 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:13:39 +0200 Subject: [PATCH 02/14] Add test for timing protocol selection in clients --- internal/airplay/mirror_clock_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/airplay/mirror_clock_test.go b/internal/airplay/mirror_clock_test.go index 593d17c..eefa978 100644 --- a/internal/airplay/mirror_clock_test.go +++ b/internal/airplay/mirror_clock_test.go @@ -218,6 +218,20 @@ func TestTimingProtocolForSession(t *testing.T) { } } +func TestTimingProtocolForClientUsesAdvertisedPTP(t *testing.T) { + legacy := &AirPlayClient{info: &ReceiverInfo{}} + if got := timingProtocolForClient(legacy, false); got != timingProtocolNTP { + t.Fatalf("legacy without PTPInfo = %q, want NTP", got) + } + ptpTV := &AirPlayClient{info: &ReceiverInfo{hasPTPInfo: true}} + if got := timingProtocolForClient(ptpTV, false); got != timingProtocolPTP { + t.Fatalf("third-party with PTPInfo = %q, want PTP", got) + } + if got := timingProtocolForClient(&AirPlayClient{info: &ReceiverInfo{hasPTPInfo: true}}, true); got != timingProtocolPTP { + t.Fatalf("modern + PTPInfo = %q, want PTP", got) + } +} + func TestModernSessionSetupRequiresFirstPartyProfile(t *testing.T) { const rokuFeatures = uint64(0x38bcf46007f8ad0) From a1fa712655dd5178809c56f851db0ea298df79ea Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:13:58 +0200 Subject: [PATCH 03/14] Implement tests for selectAudioSecurityMode function Add tests for audio security mode selection. --- internal/airplay/mirror_setup_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/airplay/mirror_setup_test.go b/internal/airplay/mirror_setup_test.go index a66ac5f..1a7e43d 100644 --- a/internal/airplay/mirror_setup_test.go +++ b/internal/airplay/mirror_setup_test.go @@ -22,6 +22,15 @@ type rtspTestRequest struct { headers map[string]string } +func TestSelectAudioSecurityMode(t *testing.T) { + if got := selectAudioSecurityMode(false); got != audioSecurityLegacyAES { + t.Fatalf("plaintext session audio mode = %v, want AES/legacy", got) + } + if got := selectAudioSecurityMode(true); got != audioSecurityChaCha { + t.Fatalf("encrypted HAP session audio mode = %v, want ChaCha", got) + } +} + func TestSourceVersionForSession(t *testing.T) { if got := sourceVersionForSession(false); got != legacyAirPlaySourceVersion { t.Fatalf("legacy source version = %q, want %q", got, legacyAirPlaySourceVersion) From 99fc2f99ea90366f586b5ceabf22e1ad7c171184 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:14:16 +0200 Subject: [PATCH 04/14] Refactor audio security mode and timing protocol handling --- internal/airplay/mirror.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go index ff7f83f..d832570 100644 --- a/internal/airplay/mirror.go +++ b/internal/airplay/mirror.go @@ -135,6 +135,10 @@ type MirrorSession struct { } func selectAudioSecurityMode(encrypted bool) audioSecurityMode { + // Encrypted pair-verify sessions (Apple and third-party HAP) encrypt audio + // with a stream key advertised as shk. FairPlay ekey is a separate path and + // is not available on TVs that omit FPSAP. The SETUP *shape* (controlPort + // vs streamConnections) is chosen later from usesModernSessionSetup(). if encrypted { return audioSecurityChaCha } @@ -155,6 +159,17 @@ func timingProtocolForSession(modern bool) string { return timingProtocolNTP } +func (i *ReceiverInfo) advertisesPTP() bool { + return i != nil && i.hasPTPInfo +} + +func timingProtocolForClient(c *AirPlayClient, modern bool) string { + if modern || c != nil && c.info.advertisesPTP() { + return timingProtocolPTP + } + return timingProtocolNTP +} + func (c *AirPlayClient) usesModernSessionSetup() bool { return c.encrypted && c.info != nil && c.info.usesModernPairing() } @@ -240,7 +255,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig senderName := pairingClientName() modernSession := c.usesModernSessionSetup() sourceVersion := sourceVersionForSession(modernSession) - timingProtocol := timingProtocolForSession(modernSession) + timingProtocol := timingProtocolForClient(c, modernSession) var clock *mediaClock if timingProtocol == timingProtocolPTP { clock = &mediaClock{} @@ -475,13 +490,19 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig audioStreamDesc["redundantAudio"] = int64(2) } - // Modern HAP receivers look for shk on the audio stream descriptor. - modernAudio := audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 + // Modern Apple SETUP replaces controlPort with streamConnections. + // Third-party HAP TVs accepted PTP + controlPort; they still need shk or + // they silently drop plaintext ALAC. + modernAudio := modernSession && audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 if modernAudio { addModernScreenAudioStreamFields(audioStreamDesc, audioChaChaKey, audioControlLPort) dbg("[SETUP] audio stream descriptor includes shk (%d bytes)", len(audioChaChaKey)) } else { audioStreamDesc["controlPort"] = int64(audioControlLPort) + if audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 { + audioStreamDesc["shk"] = audioChaChaKey + dbg("[SETUP] audio stream descriptor includes shk (%d bytes) with legacy controlPort", len(audioChaChaKey)) + } } var audioSetupPlist map[string]interface{} if modernControlSetup { @@ -517,7 +538,9 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig skipRecord, _ = audioResp["skipRecord"].(bool) if timingProtocol == timingProtocolPTP { if err := clock.configureFromSetup(audioResp, audioRespHeaders, audioRespReceivedAt); err != nil { - return nil, fmt.Errorf("configure PTP media clock: %w", err) + // Third-party TVs advertise PTPInfo but often omit Apple clock + // headers. Keep the session; frames fall back to local time. + dbg("[PTP] %v; using local timestamps", err) } } receiverEventPort = plistInt(audioResp["eventPort"]) From 79d35e2d65309060ca71b891ae7d207c942dd2f4 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:31:23 +0200 Subject: [PATCH 05/14] Pass audio codec to StartAudioCapture function --- cmd/doubletake/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/doubletake/main.go b/cmd/doubletake/main.go index 772c7cf..b972a84 100644 --- a/cmd/doubletake/main.go +++ b/cmd/doubletake/main.go @@ -363,7 +363,7 @@ func main() { // Start audio capture and streaming unless disabled. if !*noAudio && session.HasAudio() { - audioCapture, err := airplay.StartAudioCapture(ctx, *testMode) + audioCapture, err := airplay.StartAudioCapture(ctx, *testMode, session.AudioCodec()) if err != nil { log.Printf("warning: audio capture failed: %v (continuing without audio)", err) } else { From f7b7343cf0057fae218ed397b625c17554437486 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:32:22 +0200 Subject: [PATCH 06/14] Implement AAC ELD encoder in aac_eld.go --- internal/airplay/aac_eld.go | 93 +++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 internal/airplay/aac_eld.go diff --git a/internal/airplay/aac_eld.go b/internal/airplay/aac_eld.go new file mode 100644 index 0000000..bf1b42a --- /dev/null +++ b/internal/airplay/aac_eld.go @@ -0,0 +1,93 @@ +package airplay + +/* +#cgo pkg-config: fdk-aac +#include + +static AACENC_ERROR eld_open(HANDLE_AACENCODER *enc) { + AACENC_ERROR err; + if ((err = aacEncOpen(enc, 0, 2)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_AOT, 39)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_SAMPLERATE, 44100)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_CHANNELMODE, 2)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_CHANNELORDER, 1)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_BITRATE, 128000)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_TRANSMUX, 0)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_SBR_MODE, 0)) != AACENC_OK) return err; + if ((err = aacEncoder_SetParam(*enc, AACENC_GRANULE_LENGTH, 480)) != AACENC_OK) return err; + return aacEncEncode(*enc, NULL, NULL, NULL, NULL); +} + +static AACENC_ERROR eld_frame_length(HANDLE_AACENCODER enc, UINT *frameLength) { + AACENC_InfoStruct info; + AACENC_ERROR err = aacEncInfo(enc, &info); + if (err == AACENC_OK) *frameLength = info.frameLength; + return err; +} + +static AACENC_ERROR eld_encode(HANDLE_AACENCODER enc, INT_PCM *pcm, INT nSamples, UCHAR *out, INT outSize, INT *nOut) { + AACENC_BufDesc inDesc = {0}, outDesc = {0}; + AACENC_InArgs inArgs = {0}; + AACENC_OutArgs outArgs = {0}; + void *inBufs[1] = {pcm}, *outBufs[1] = {out}; + INT inIds[1] = {IN_AUDIO_DATA}, outIds[1] = {OUT_BITSTREAM_DATA}; + INT inSizes[1] = {nSamples * (INT)sizeof(INT_PCM)}, outSizes[1] = {outSize}; + INT inElSizes[1] = {(INT)sizeof(INT_PCM)}, outElSizes[1] = {1}; + inDesc.numBufs = outDesc.numBufs = 1; + inDesc.bufs = inBufs; inDesc.bufferIdentifiers = inIds; inDesc.bufSizes = inSizes; inDesc.bufElSizes = inElSizes; + outDesc.bufs = outBufs; outDesc.bufferIdentifiers = outIds; outDesc.bufSizes = outSizes; outDesc.bufElSizes = outElSizes; + inArgs.numInSamples = nSamples; + AACENC_ERROR err = aacEncEncode(enc, &inDesc, &outDesc, &inArgs, &outArgs); + *nOut = outArgs.numOutBytes; + return err; +} +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +type eldEncoder struct { + enc C.HANDLE_AACENCODER + frameLen int + outBuf []byte +} + +func newELDEncoder(_, _, _ int) (*eldEncoder, error) { + var enc C.HANDLE_AACENCODER + if err := C.eld_open(&enc); err != C.AACENC_OK { + if enc != nil { + C.aacEncClose(&enc) + } + return nil, fmt.Errorf("open AAC-ELD encoder: %d", int(err)) + } + var frameLen C.UINT + if err := C.eld_frame_length(enc, &frameLen); err != C.AACENC_OK { + C.aacEncClose(&enc) + return nil, fmt.Errorf("read AAC-ELD encoder info: %d", int(err)) + } + return &eldEncoder{enc: enc, frameLen: int(frameLen), outBuf: make([]byte, 2048)}, nil +} + +func (e *eldEncoder) Encode(pcm, out []byte) (int, error) { + var n C.INT + err := C.eld_encode(e.enc, (*C.INT_PCM)(unsafe.Pointer(&pcm[0])), C.INT(e.frameLen*2), + (*C.UCHAR)(unsafe.Pointer(&e.outBuf[0])), C.INT(len(e.outBuf)), &n) + if err != C.AACENC_OK { + return 0, fmt.Errorf("encode AAC-ELD: %d", int(err)) + } + if int(n) > len(out) { + return 0, fmt.Errorf("AAC-ELD frame exceeds buffer") + } + copy(out, e.outBuf[:n]) + return int(n), nil +} + +func (e *eldEncoder) Close() { + if e != nil && e.enc != nil { + C.aacEncClose(&e.enc) + e.enc = nil + } +} From 5183cf707014f5e64e8f5cd8fcd014a4a655b0ec Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:32:44 +0200 Subject: [PATCH 07/14] Add AAC codec support to audio capture --- internal/airplay/audio.go | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/airplay/audio.go b/internal/airplay/audio.go index 184c2b8..52ffc0b 100644 --- a/internal/airplay/audio.go +++ b/internal/airplay/audio.go @@ -26,6 +26,7 @@ type audioChaChaAADMode int const ( AudioCodecALAC AudioCodec = 2 // ct=2, spf=352, audioFormat=0x40000 + AudioCodecAAC AudioCodec = 7 // ct=7, spf=480, audioFormat=0x1000000 audioSecurityLegacyAES audioSecurityMode = iota audioSecurityChaCha @@ -64,6 +65,9 @@ func defaultAudioChaChaAADMode() audioChaChaAADMode { // Info returns SETUP parameters for the supported mirrored-audio codec. func (c AudioCodec) Info() (ct int64, spf int64, audioFormat int64, latencyMin int64, latencyMax int64, latencySamples uint32) { latency := targetLatencySamples44k1() + if c == AudioCodecAAC { + return 7, 480, 0x1000000, 0, int64(latency), latency + } return 2, 352, 0x40000, 0, int64(latency), latency } @@ -91,11 +95,12 @@ type AudioCapture struct { waitCh chan struct{} waitErr error stopped bool + eld *eldEncoder } // StartAudioCapture launches a pipeline that captures system audio (monitor source) // and feeds raw PCM into the built-in ALAC encoder. -func StartAudioCapture(ctx context.Context, testTone bool) (*AudioCapture, error) { +func StartAudioCapture(ctx context.Context, testTone bool, codec AudioCodec) (*AudioCapture, error) { captureCtx, cancel := context.WithCancel(ctx) // Detect audio source @@ -124,6 +129,14 @@ func StartAudioCapture(ctx context.Context, testTone bool) (*AudioCapture, error cancel: cancel, waitCh: make(chan struct{}), } + if codec == AudioCodecAAC { + var err error + ac.eld, err = newELDEncoder(44100, 480, 128000) + if err != nil { + cancel() + return nil, err + } + } gstArgs := []string{"--quiet"} gstArgs = append(gstArgs, srcArgs...) @@ -170,6 +183,13 @@ func (ac *AudioCapture) ReadFrame(buf []byte) (int, error) { return 0, io.EOF default: } + if ac.eld != nil { + pcm := make([]byte, ac.eld.frameLen*2*2) + if _, err := io.ReadFull(ac.pcmPipe, pcm); err != nil { + return 0, err + } + return ac.eld.Encode(pcm, buf) + } const spf = 352 const channels = 2 @@ -229,6 +249,9 @@ func (ac *AudioCapture) Stop() { return } ac.stopped = true + if ac.eld != nil { + ac.eld.Close() + } if ac.cancel != nil { ac.cancel() } @@ -420,7 +443,8 @@ func (s *MirrorSession) setupAudioStream(dataPort, controlPort int, aesKey, aesI } } - spf := uint16(352) + _, codecSPF, _, _, _, _ := AudioCodec(ct).Info() + spf := uint16(codecSPF) latencySamples := audioLatencySamplesForCodec(ct, latencyOverride) // Apple senders use SSRC=0 for mirroring audio RTP. From 378415b502b9569cbd047c9efe81e06f530e4453 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:33:17 +0200 Subject: [PATCH 08/14] Add boot clock functionality for Linux --- internal/airplay/boot_clock_linux.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 internal/airplay/boot_clock_linux.go diff --git a/internal/airplay/boot_clock_linux.go b/internal/airplay/boot_clock_linux.go new file mode 100644 index 0000000..54d1c7a --- /dev/null +++ b/internal/airplay/boot_clock_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package airplay + +import ( + "time" + + "golang.org/x/sys/unix" +) + +func bootRelativeNow() time.Duration { + var ts unix.Timespec + if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &ts); err == nil { + return time.Duration(ts.Sec)*time.Second + time.Duration(ts.Nsec) + } + return time.Since(appStartTime) +} From 750ace67b088b5baee79e5e76bc580ff3b9f1f34 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:33:42 +0200 Subject: [PATCH 09/14] Add bootRelativeNow function for time duration --- internal/airplay/boot_clock_other.go | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 internal/airplay/boot_clock_other.go diff --git a/internal/airplay/boot_clock_other.go b/internal/airplay/boot_clock_other.go new file mode 100644 index 0000000..396a47f --- /dev/null +++ b/internal/airplay/boot_clock_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package airplay + +import "time" + +func bootRelativeNow() time.Duration { + return time.Since(appStartTime) +} From 1067b8f6458efb7b7006ae5b87c343f8cf116636 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:34:03 +0200 Subject: [PATCH 10/14] Implement video scaling in capture.go Add video scaling support based on MaxWidth and MaxHeight configuration. --- internal/airplay/capture.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/airplay/capture.go b/internal/airplay/capture.go index 122aae5..7750da3 100644 --- a/internal/airplay/capture.go +++ b/internal/airplay/capture.go @@ -339,6 +339,13 @@ func startX11Capture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, er } beforeConvert := []gstStage{frameRateStage(fps), lowLatencyVideoQueueStage()} + if cfg.MaxWidth > 0 && cfg.MaxHeight > 0 { + // Fold size into the existing videoconvert caps. A second video/x-raw + // filter is parsed by gst-launch as element "video" (the '/' pad syntax). + beforeConvert = append(beforeConvert, gstStage{"videoscale", "add-borders=true"}) + encoder.rawFormat = fmt.Sprintf("%s,width=%d,height=%d", encoder.rawFormat, cfg.MaxWidth, cfg.MaxHeight) + dbg("[CAPTURE] scaling X11 capture to %dx%d", cfg.MaxWidth, cfg.MaxHeight) + } gstArgs := buildGstVideoPipeline(ximageSrcArgs, beforeConvert, nil, encoder) dbg("[CAPTURE] gst-launch-1.0 (x11) %s", strings.Join(gstArgs, " ")) From 7002f6b235f76b6950e66cc44949df78b4ec41c4 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:34:22 +0200 Subject: [PATCH 11/14] Refactor display size handling and clean up code --- internal/airplay/client.go | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/airplay/client.go b/internal/airplay/client.go index a358415..14dabd3 100644 --- a/internal/airplay/client.go +++ b/internal/airplay/client.go @@ -42,7 +42,6 @@ type ReceiverInfo struct { PI string `plist:"pi"` MacAddress string `plist:"macAddress"` Displays []DisplayInfo `plist:"displays"` - hasPTPInfo bool } // AirPlay receiver status flags used to choose one authentication prompt. @@ -82,7 +81,15 @@ type DisplayInfo struct { // codec header uses this as the presentation (display) size so the receiver // can center/pillarbox content whose aspect ratio differs from the display. func (i *ReceiverInfo) DisplaySize() (int, int) { - if i == nil || len(i.Displays) == 0 { + if i == nil { + return 0, 0 + } + // AirTame / AirServer omit displays[] but drive a 1080p Vivante framebuffer. + // Encoding the sender desktop at native 2560x1440 stays black on that VPU. + if len(i.Displays) == 0 && i.looksLikeAirServerClone() { + return 1920, 1080 + } + if len(i.Displays) == 0 { return 0, 0 } d := i.Displays[0] @@ -282,7 +289,7 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) { } return keys }()) - for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats", "PTPInfo"} { + for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats"} { if v, ok := fullInfo[key]; ok { dbg("[INFO] %s: %+v", key, v) } @@ -293,9 +300,6 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) { if _, err := plist.Unmarshal(resp, &info); err != nil { return nil, fmt.Errorf("decode info plist: %w", err) } - if _, ok := fullInfo["PTPInfo"]; ok { - info.hasPTPInfo = true - } c.info = &info return &info, nil } @@ -809,15 +813,26 @@ func (mc *mirrorCipher) EncryptFrame(payload []byte) []byte { // Step 1: XOR prefix bytes using cached keystream from previous frame's // trailing partial block (matches receiver's og buffer usage). if mc.nextCryptCount > 0 { - n := mc.nextCryptCount + available := mc.nextCryptCount + n := available if n > inputLen { n = inputLen } - ogStart := 16 - mc.nextCryptCount + ogStart := 16 - available for i := 0; i < n; i++ { out[i] = payload[i] ^ mc.og[ogStart+i] } pos = n + if n < available { + // Keep the unused suffix in the same right-aligned layout used by + // the next frame. Small VCL payloads can consume this cached block + // over more than one frame. + remaining := available - n + copy(mc.og[16-remaining:], mc.og[ogStart+n:]) + mc.nextCryptCount = remaining + return out + } + mc.nextCryptCount = 0 } // Step 2: Advance CTR to next 16-byte boundary (aes_ctr_start_fresh_block). @@ -839,7 +854,6 @@ func (mc *mirrorCipher) EncryptFrame(payload []byte) []byte { // Step 4: Handle trailing partial block. restLen := remaining % 16 - mc.nextCryptCount = 0 if restLen > 0 { // Pad input to 16 bytes, encrypt full block, use first restLen bytes. var padded [16]byte From b89130edf10af7e7c7a017f681a4d5c2e5827e17 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:34:58 +0200 Subject: [PATCH 12/14] Implement looksLikeAirServerClone method Added a method to identify AirServer clones based on model and features. --- internal/airplay/discovery.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/airplay/discovery.go b/internal/airplay/discovery.go index acb4362..b226241 100644 --- a/internal/airplay/discovery.go +++ b/internal/airplay/discovery.go @@ -276,11 +276,23 @@ func supportsTransientPairing(features uint64) bool { return features&(FeatureTransientPairing|FeatureSystemPairing) != 0 } +// looksLikeAirServerClone reports receivers that impersonate a first-party +// Apple TV in /info (model + CoreUtils feature bits) but omit a display list +// and do not speak CoreUtils pair-setup. AirTame 2 / AirServer answer as +// AppleTV5,3 with AirPlay/375.3 and reject modern HKP type 5 M1 with HTTP 500. +func (i *ReceiverInfo) looksLikeAirServerClone() bool { + return i != nil && + i.Model == "AppleTV5,3" && + len(i.Displays) == 0 && + i.Features&featureThirdPartyReceiverMask == 0 +} + // usesModernPairing reports whether the receiver can use the first-party // CoreUtils/HAP profile directly. Third-party receivers retain HKP type 3 and // legacy session setup even when they copy the modern pairing feature bits. func (i *ReceiverInfo) usesModernPairing() bool { return i != nil && + !i.looksLikeAirServerClone() && i.Features&featureCoreUtilsPairingMask != 0 && i.Features&featureThirdPartyReceiverMask == 0 } From 86ed6988859a9208affb2558fec6370a4f646118 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:35:21 +0200 Subject: [PATCH 13/14] Refactor audio security and session setup logic Refactor audio security mode selection and session setup logic. Update handling of timing protocols and improve compatibility with AirServer clones. --- internal/airplay/mirror.go | 151 +++++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 47 deletions(-) diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go index d832570..e2cfe76 100644 --- a/internal/airplay/mirror.go +++ b/internal/airplay/mirror.go @@ -1,6 +1,7 @@ package airplay import ( + "bytes" "context" "crypto/cipher" "crypto/rand" @@ -135,10 +136,6 @@ type MirrorSession struct { } func selectAudioSecurityMode(encrypted bool) audioSecurityMode { - // Encrypted pair-verify sessions (Apple and third-party HAP) encrypt audio - // with a stream key advertised as shk. FairPlay ekey is a separate path and - // is not available on TVs that omit FPSAP. The SETUP *shape* (controlPort - // vs streamConnections) is chosen later from usesModernSessionSetup(). if encrypted { return audioSecurityChaCha } @@ -159,21 +156,17 @@ func timingProtocolForSession(modern bool) string { return timingProtocolNTP } -func (i *ReceiverInfo) advertisesPTP() bool { - return i != nil && i.hasPTPInfo -} - -func timingProtocolForClient(c *AirPlayClient, modern bool) string { - if modern || c != nil && c.info.advertisesPTP() { - return timingProtocolPTP - } - return timingProtocolNTP -} - func (c *AirPlayClient) usesModernSessionSetup() bool { return c.encrypted && c.info != nil && c.info.usesModernPairing() } +// usesSessionFirstSetup reports whether SETUP must create the session before +// any media stream. Real Apple senders and AirServer clones (AirTame) reject a +// second full session plist on the video SETUP with HTTP 400. +func (c *AirPlayClient) usesSessionFirstSetup() bool { + return c.usesModernSessionSetup() || (c.info != nil && c.info.looksLikeAirServerClone()) +} + type mirrorSetupRequest struct { deviceID string sessionUUID string @@ -255,7 +248,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig senderName := pairingClientName() modernSession := c.usesModernSessionSetup() sourceVersion := sourceVersionForSession(modernSession) - timingProtocol := timingProtocolForClient(c, modernSession) + timingProtocol := timingProtocolForSession(modernSession) var clock *mediaClock if timingProtocol == timingProtocolPTP { clock = &mediaClock{} @@ -350,10 +343,13 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig // AirPlay prepares the receiver with a control-only SETUP before creating // media streams. This ordering matters: the receiver starts an audio packet // processor only when type 96 is created after the session is prepared. - modernControlSetup := modernSession + modernControlSetup := c.usesSessionFirstSetup() audioStreamConnectionID := int64(time.Now().UnixNano() & 0x7FFFFFFFFFFFFFFF) selectedAudioCodec := AudioCodecALAC + if c.info.looksLikeAirServerClone() { + selectedAudioCodec = AudioCodecAAC + } // Real Apple senders use streamConnectionID as the RTSP URI path. // Control, audio, RECORD, and SET_PARAMETER share the audio URI; video uses // a separate URI with its own streamConnectionID. @@ -448,13 +444,29 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig if modernControlSetup { dbg("[SETUP] phase 1 (control): preparing media session") - controlResp, controlHeaders, receivedAt, err := sendSetup(audioURI, "control", setupRequest.controlPlist()) + controlPlist := setupRequest.controlPlist() + // AirServer clones accept a session-first SETUP but still expect FairPlay + // ekey/eiv on that first plist. Stream-only follow-ups never install a master. + if c.info.looksLikeAirServerClone() && c.FpEkey != nil && c.fpIV != nil { + controlPlist["et"] = int64(32) + controlPlist["ekey"] = c.FpEkey + controlPlist["eiv"] = c.fpIV + dbg("[SETUP] control SETUP includes FairPlay ekey=%d bytes, eiv=%d bytes", len(c.FpEkey), len(c.fpIV)) + } + controlResp, controlHeaders, receivedAt, err := sendSetup(audioURI, "control", controlPlist) if err != nil { return nil, err } + if timingProtocol == timingProtocolNTP { + if receiverTimingPort := plistInt(controlResp["timingPort"]); receiverTimingPort > 0 { + go sendNTPTimingProbes(sessionCtx, timingConn, c.host, receiverTimingPort) + } + } skipRecord, _ = controlResp["skipRecord"].(bool) - if err := clock.configureFromSetup(controlResp, controlHeaders, receivedAt); err != nil { - return nil, fmt.Errorf("configure PTP media clock: %w", err) + if timingProtocol == timingProtocolPTP { + if err := clock.configureFromSetup(controlResp, controlHeaders, receivedAt); err != nil { + return nil, fmt.Errorf("configure PTP media clock: %w", err) + } } receiverEventPort = plistInt(controlResp["eventPort"]) if err := connectEvent(); err != nil { @@ -486,27 +498,26 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig "latencyMin": latMin, "latencyMax": latMax, } - if useAudioFEC(audioMode == audioSecurityChaCha) { + if selectedAudioCodec != AudioCodecAAC && useAudioFEC(audioMode == audioSecurityChaCha) { audioStreamDesc["redundantAudio"] = int64(2) } - // Modern Apple SETUP replaces controlPort with streamConnections. - // Third-party HAP TVs accepted PTP + controlPort; they still need shk or - // they silently drop plaintext ALAC. - modernAudio := modernSession && audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 + // Modern HAP receivers look for shk on the audio stream descriptor. + modernAudio := audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 if modernAudio { addModernScreenAudioStreamFields(audioStreamDesc, audioChaChaKey, audioControlLPort) dbg("[SETUP] audio stream descriptor includes shk (%d bytes)", len(audioChaChaKey)) } else { audioStreamDesc["controlPort"] = int64(audioControlLPort) - if audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 { - audioStreamDesc["shk"] = audioChaChaKey - dbg("[SETUP] audio stream descriptor includes shk (%d bytes) with legacy controlPort", len(audioChaChaKey)) - } } var audioSetupPlist map[string]interface{} if modernControlSetup { audioSetupPlist = streamOnlyPlist(audioStreamDesc) + if c.info.looksLikeAirServerClone() && c.FpEkey != nil && c.fpIV != nil { + audioSetupPlist["et"] = int64(32) + audioSetupPlist["ekey"] = c.FpEkey + audioSetupPlist["eiv"] = c.fpIV + } } else { audioSetupPlist = setupRequest.legacyStreamPlist(audioStreamDesc) if !modernAudio { @@ -538,9 +549,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig skipRecord, _ = audioResp["skipRecord"].(bool) if timingProtocol == timingProtocolPTP { if err := clock.configureFromSetup(audioResp, audioRespHeaders, audioRespReceivedAt); err != nil { - // Third-party TVs advertise PTPInfo but often omit Apple clock - // headers. Keep the session; frames fall back to local time. - dbg("[PTP] %v; using local timestamps", err) + return nil, fmt.Errorf("configure PTP media clock: %w", err) } } receiverEventPort = plistInt(audioResp["eventPort"]) @@ -598,13 +607,12 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig videoSetupPlist = streamOnlyPlist(videoStreamDesc) } else { videoSetupPlist = setupRequest.legacyStreamPlist(videoStreamDesc) - // UxPlay reads ekey/eiv from the root level of SETUP to derive the - // video decryption key. - if c.FpEkey != nil && encKey != nil { - videoSetupPlist["ekey"] = c.FpEkey - videoSetupPlist["eiv"] = encIV - dbg("[SETUP] video SETUP includes FairPlay ekey=%d bytes, eiv=%d bytes", len(c.FpEkey), len(encIV)) - } + } + // UxPlay and AirServer read ekey/eiv from the root of a video SETUP. + if c.FpEkey != nil && encKey != nil && (c.info.looksLikeAirServerClone() || !modernControlSetup) { + videoSetupPlist["ekey"] = c.FpEkey + videoSetupPlist["eiv"] = encIV + dbg("[SETUP] video SETUP includes FairPlay ekey=%d bytes, eiv=%d bytes", len(c.FpEkey), len(encIV)) } dbg("[SETUP] phase %d (video): streamConnectionID=%d", videoPhase, videoStreamConnectionID) @@ -806,6 +814,14 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig return session, nil } +// AudioCodec returns the codec negotiated for this mirror session. +func (s *MirrorSession) AudioCodec() AudioCodec { + if s.audioStream == nil { + return AudioCodecALAC + } + return AudioCodec(s.audioStream.ct) +} + func addModernScreenAudioStreamFields(stream map[string]interface{}, key []byte, controlPort int) { // Modern connection dictionaries replace the legacy top-level controlPort. delete(stream, "controlPort") @@ -846,10 +862,11 @@ func (s *MirrorSession) StreamFrames(ctx context.Context, capture *ScreenCapture parser := newH264Parser() var latestSPS, latestPPS []byte // raw NAL data WITHOUT start code - var vclBuf []byte // AVCC-formatted data accumulating for current access unit - var pendingKeyframe bool // true if vclBuf contains IDR slice(s) - var codecSent bool // true if codec frame sent for current keyframe - var streamPrimed bool // true after first SPS/PPS+IDR has been sent + var sentSPS, sentPPS []byte + var vclBuf []byte // AVCC-formatted data accumulating for current access unit + var pendingKeyframe bool // true if vclBuf contains IDR slice(s) + var codecSent bool // true if codec frame sent for current keyframe + var streamPrimed bool // true after first SPS/PPS+IDR has been sent var frameCount int var lastProgressLog time.Time var nalLog strings.Builder @@ -875,7 +892,8 @@ func (s *MirrorSession) StreamFrames(ctx context.Context, capture *ScreenCapture packetTimestamp, packetTimeline := s.frameTimeNow() // Send SPS+PPS as unencrypted avcC codec frame before keyframes - if pendingKeyframe && !codecSent && latestSPS != nil && latestPPS != nil { + if pendingKeyframe && !codecSent && latestSPS != nil && latestPPS != nil && + (!streamPrimed || !bytes.Equal(latestSPS, sentSPS) || !bytes.Equal(latestPPS, sentPPS)) { // Derive the encoded content dimensions from the SPS itself so the // codec header reports exactly what the encoder produced, regardless // of the captured surface size (which we no longer pin to a config @@ -899,6 +917,8 @@ func (s *MirrorSession) StreamFrames(ctx context.Context, capture *ScreenCapture } codecSent = true streamPrimed = true + sentSPS = append(sentSPS[:0], latestSPS...) + sentPPS = append(sentPPS[:0], latestPPS...) } frameData := vclBuf @@ -1807,6 +1827,12 @@ func ntpTimingResponder(ctx context.Context, conn net.PacketConn) { if n < 32 { continue } + if buf[0] == 0x80 && buf[1] == 0xd3 { + continue + } + if buf[0] != 0x80 || buf[1] != 0xd2 { + continue + } // Log the Apple TV's send timestamp for timing analysis senderTS := binary.BigEndian.Uint64(buf[24:32]) @@ -1839,6 +1865,37 @@ func ntpTimingResponder(ctx context.Context, conn net.PacketConn) { } } +// sendNTPTimingProbes initiates timing with receivers that return their own +// timing port instead of probing the sender first. +func sendNTPTimingProbes(ctx context.Context, conn net.PacketConn, host string, port int) { + addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + dbg("[NTP] resolve receiver timing port: %v", err) + return + } + for sequence := uint16(1); sequence <= 3; sequence++ { + request := make([]byte, 32) + request[0], request[1] = 0x80, 0xd2 + binary.BigEndian.PutUint16(request[2:4], sequence) + binary.BigEndian.PutUint64(request[24:32], ntpBootTimestamp()) + if _, err := conn.WriteTo(request, addr); err != nil { + dbg("[NTP] send timing probe to %s: %v", addr, err) + return + } + if sequence < 3 { + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-timer.C: + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return + } + } + } +} + // uuidToMAC converts a UUID-ish string to a stable locally-administered MAC address. // Falls back to a fixed MAC if the UUID does not contain enough hex digits. func uuidToMAC(id string) string { @@ -1859,7 +1916,7 @@ func uuidToMAC(id string) string { return strings.ToUpper(strings.Join(parts, ":")) } -// appStartTime is the reference point for boot-relative timestamps. +// appStartTime is the fallback reference point when no system boot clock is available. var appStartTime = time.Now() // ntpTimeNow returns a 64-bit NTP fixed-point timestamp for mirroring frame headers. @@ -1918,7 +1975,7 @@ func ntpTimeWithBias(bias time.Duration) uint64 { if bias < 5*time.Millisecond { bias = 5 * time.Millisecond } - return compactTimestamp(time.Since(appStartTime) + bias) + return compactTimestamp(bootRelativeNow() + bias) } func compactTimestamp(d time.Duration) uint64 { @@ -1999,7 +2056,7 @@ func tryConsecutiveUDP(base, count int) ([]net.PacketConn, bool) { const secondsFrom1900To1970 = 2208988800 func ntpBootTimestamp() uint64 { - d := time.Since(appStartTime) + d := bootRelativeNow() sec := uint64(d/time.Second) + secondsFrom1900To1970 nsecFrac := uint64(d % time.Second) frac := (nsecFrac << 32) / uint64(time.Second) From 7a47bcf8dd88479bcdd74f59683a7bf57337420a Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:35:46 +0200 Subject: [PATCH 14/14] Pass audio codec to StartAudioCapture function --- internal/daemon/daemon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 2923c63..9687836 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -933,7 +933,7 @@ func (d *Daemon) connectAndStream(ctx context.Context, entry *activeStream, targ // Start audio for this stream independently. if !d.cfg.NoAudio && session.HasAudio() { - audioCapture, audioErr := airplay.StartAudioCapture(ctx, d.cfg.TestMode) + audioCapture, audioErr := airplay.StartAudioCapture(ctx, d.cfg.TestMode, session.AudioCodec()) if audioErr != nil { log.Printf("[daemon] audio capture failed: %v (continuing without audio)", audioErr) } else {