Skip to content

Support third-party HAP TVs that advertise PTP (LG 75UP75009LC) - #31

Draft
3rd3 wants to merge 4 commits into
omarroth:mainfrom
3rd3:main
Draft

Support third-party HAP TVs that advertise PTP (LG 75UP75009LC)#31
3rd3 wants to merge 4 commits into
omarroth:mainfrom
3rd3:main

Conversation

@3rd3

@3rd3 3rd3 commented Aug 13, 2026

Copy link
Copy Markdown

Summary

Pairing and video SETUP failed on an LG webOS AirPlay receiver (AirTunes/377.25.06, features=0x38bcb46007f8ad0). Pair-verify succeeded; audio SETUP waited ~20s and returned 400. After using PTP, video worked but audio was silent.

The TV is classified as third-party (not modern Apple), so we used NTP + a combined audio SETUP. It advertises PTPInfo and never sends an NTP probe (same as iPhone/Mac on that LAN). After PTP, audio was still sent in the clear because there is no FairPlay SAP; the TV drops that.

Changes

  • Record PTPInfo from /info and select PTP independently of usesModernSessionSetup().
  • Keep legacy SETUP (sourceVersion=280.33, controlPort, no control-only first SETUP).
  • On encrypted pair-verify sessions, put a 32-byte ChaCha shk on the audio stream even when controlPort is still used.
  • If PTP clock headers are missing, log and continue instead of failing SETUP.

Tested

  • LG 75UP75009LC: pairing, video, and audio.
  • go test ./internal/airplay/

@3rd3

3rd3 commented Aug 20, 2026

Copy link
Copy Markdown
Author

Confirmed to work with the 75UR781C0LK model as well.

Model Product family Year OS
LG 75UP75009LC LG UHD TV UP7500 series 2021 webOS 6.0
LG 75UR781C0LK LG UHD TV UR78 series 2023 webOS 23

So, this likely works with all LG models with webOS 6.0+.
LG 2019–2020 TVs (webOS 4.5–5.x) still need testing.

@3rd3

3rd3 commented Aug 22, 2026

Copy link
Copy Markdown
Author

Summary

Commit cf95085 attempted to incorporate the receiver compatibility work from PR #31 while unifying the AirPlay 1 and AirPlay 2 pairing flows. However, parts of PR #31’s behavior were lost during that refactor, causing regressions on LG TVs:

  • mirroring initially failed because the receiver omitted timingPeerInfo.ClockID;
  • partial fallback attempts displayed only the first video frame;
  • audio failed because PTP TimeAnnounce required a missing timeline ID;
  • startup paused for approximately five seconds after audio capture started.

This PR ports the missing semantics from PR #31 onto the current dev implementation without reverting the newer pairing and compatibility architecture introduced by cf95085.

Changes

  • Restore PR Support third-party HAP TVs that advertise PTP (LG 75UP75009LC) #31’s compatibility path for encrypted third-party receivers that advertise PTP but do not support FairPlay SAP.
  • Use the legacy 280.33 source version and media-first SETUP sequence for this receiver profile.
  • Preserve ChaCha20-Poly1305 audio encryption with the legacy controlPort descriptor.
  • Apply a conservative 500 ms playout latency.
  • Fall back to app-relative timestamps with timeline ID zero when the receiver omits Apple-specific PTP clock metadata.
  • Use legacy NTP TimeAnnounce packets for audio when no PTP timeline ID is available.
  • Update LG receiver integration expectations and add regression coverage for audio timing fallback.

Five-second startup delay

The delay was unrelated to network setup or PTP overflow.

BroadcastCapture started before its video sink was attached, so the encoder’s initial SPS/PPS/IDR data was discarded. StreamFrames then waited for OpenH264’s next keyframe at the four-second GOP boundary.

The video sink is now attached before broadcast capture starts, preserving the initial keyframe and allowing transmission to begin immediately after audio capture starts.

Testing

  • Ran go test ./cmd/doubletake ./internal/airplay.
  • Built all binaries with make.
  • Tested against an LG 75UP75009LC.
  • Confirmed:
    • encrypted pairing and media setup;
    • immediate video startup without the previous five-second delay;
    • continuous video playback;
    • working audio playback;
    • clean session teardown.

Patch

From c6b971ddce4c120356ac70209e6368505838177f Mon Sep 17 00:00:00 2001
From: 3rd3
Date: Fri, 21 Aug 2026 23:51:17 +0200
Subject: [PATCH] Fix LG TV

---
 cmd/doubletake/main.go                   |  10 ++-
 internal/airplay/audio.go                |   4 +-
 internal/airplay/audio_test.go           |  10 +++
 internal/airplay/mirror.go               | 100 ++++++++++++++++++-----
 internal/airplay/receiver_server_test.go |   2 +-
 5 files changed, 102 insertions(+), 24 deletions(-)

diff --git a/cmd/doubletake/main.go b/cmd/doubletake/main.go
index 7cbb549..dcfccb1 100644
--- a/cmd/doubletake/main.go
+++ b/cmd/doubletake/main.go
@@ -365,6 +365,7 @@ func main() {
 
 	var capture *airplay.ScreenCapture
 	var broadcast *airplay.BroadcastCapture
+	var videoSink *airplay.BroadcastSink
 	var broadcastDone chan error
 	startedWidth, startedHeight := -1, -1
 	prepareVideo := func(width, height int) error {
@@ -380,6 +381,10 @@ func main() {
 		}
 		startedWidth, startedHeight = width, height
 		broadcast = airplay.NewBroadcastCapture(capture)
+		// Attach before Run starts so the initial SPS/PPS/IDR remains queued for
+		// StreamFrames. Attaching after audio setup can miss that first keyframe and
+		// wait for OpenH264's next four-second GOP boundary.
+		videoSink = broadcast.AddSink()
 		broadcastDone = make(chan error, 1)
 		go func(active *airplay.BroadcastCapture, done chan<- error) {
 			done <- active.Run()
@@ -389,6 +394,9 @@ func main() {
 	}
 	defer func() {
 		capturePreparation.Close()
+		if videoSink != nil {
+			videoSink.Close()
+		}
 		if capture != nil {
 			capture.Stop()
 			<-broadcastDone
@@ -441,8 +449,6 @@ func main() {
 		log.Println("audio disabled (receiver did not provide audio ports)")
 	}
 
-	videoSink := broadcast.AddSink()
-	defer videoSink.Close()
 	if err := session.StreamFrames(ctx, videoSink.AsCapture(), 0*time.Second); err != nil && ctx.Err() == nil {
 		log.Fatalf("streaming error: %v", err)
 	}
diff --git a/internal/airplay/audio.go b/internal/airplay/audio.go
index b8b3fcf..2033568 100644
--- a/internal/airplay/audio.go
+++ b/internal/airplay/audio.go
@@ -840,7 +840,7 @@ videoReady:
 	// packet. The reset bit is set only on this announce; subsequent 1 Hz
 	// announces update the same mapping.
 	clockNow, timelineID := s.audioClockNow()
-	if err := audioStream.sendSyncPacket(s.timingProtocol, clockNow, timelineID, true); err != nil {
+	if err := audioStream.sendSyncPacket(s.audioTimingProtocol(timelineID), clockNow, timelineID, true); err != nil {
 		return fmt.Errorf("audio initial clock mapping: %w", err)
 	}
 	dbg("[AUDIO] sent initial clock mapping with first frame ready, starting audio at rtp=%d", nextRtp)
@@ -857,7 +857,7 @@ videoReady:
 				return
 			case <-ticker.C:
 				clockNow, timelineID := s.audioClockNow()
-				if err := audioStream.sendSyncPacket(s.timingProtocol, clockNow, timelineID, false); err != nil {
+				if err := audioStream.sendSyncPacket(s.audioTimingProtocol(timelineID), clockNow, timelineID, false); err != nil {
 					dbg("[AUDIO] sync error: %v", err)
 				}
 			}
diff --git a/internal/airplay/audio_test.go b/internal/airplay/audio_test.go
index 815bebb..ec0575f 100644
--- a/internal/airplay/audio_test.go
+++ b/internal/airplay/audio_test.go
@@ -331,6 +331,16 @@ func TestSendSyncPacketUsesNegotiatedProtocolInsteadOfTimelinePresence(t *testin
 	}
 }
 
+func TestThirdPartyPTPFallbackUsesNTPAudioMapping(t *testing.T) {
+	session := &MirrorSession{timingProtocol: timingProtocolPTP, legacyLocalClock: true}
+	if got := session.audioTimingProtocol(0); got != timingProtocolNTP {
+		t.Fatalf("third-party zero-timeline audio protocol = %q, want NTP", got)
+	}
+	if got := session.audioTimingProtocol(1); got != timingProtocolPTP {
+		t.Fatalf("third-party identified-timeline audio protocol = %q, want PTP", got)
+	}
+}
+
 func TestSendSyncPacketUsesPTPTimeAnnounceWithTimeline(t *testing.T) {
 	const (
 		networkTime = uint64(0x0000000180000000) // 1.5 seconds in seconds.32
diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go
index ef6fca8..4ba05f6 100644
--- a/internal/airplay/mirror.go
+++ b/internal/airplay/mirror.go
@@ -195,6 +195,7 @@ type MirrorSession struct {
 	timestampBias      time.Duration
 	timingProtocol     string
 	mediaClock         *mediaClock
+	legacyLocalClock   bool
 
 	// Audio
 	audioStream *AudioStream
@@ -202,6 +203,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
 	}
@@ -344,6 +349,18 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
 	}
 	sourceVersion := policy.sourceVersion()
 	timingProtocol := policy.timing
+	// PR #31's working third-party PTP profile is deliberately independent from
+	// HAP control-channel encryption: it uses the legacy media-session dialect,
+	// PTP advertisement, media-first SETUP, and app-relative fallback timestamps.
+	// Do not infer this from the pairing route: current dev can successfully use
+	// HAP with third-party TVs, which makes usesModernPairing true after probing.
+	// PR #31's relevant wire profile is instead a PTP-advertising receiver that
+	// lacks FairPlay SAP, as this LG does.
+	thirdPartyPTP := policy.timing == timingProtocolPTP && c.info != nil && c.info.hasPTPInfo && !c.info.SupportsFairPlaySAP()
+	if thirdPartyPTP {
+		sourceVersion = legacyAirPlaySourceVersion
+		timingProtocol = timingProtocolPTP
+	}
 	var clock *mediaClock
 	if timingProtocol == timingProtocolPTP {
 		clock = &mediaClock{}
@@ -433,13 +450,17 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
 		dbg("[SETUP] raising NTP session latency to %v", ntpPlayoutLatencyFloor)
 		sessionLatency = ntpPlayoutLatencyFloor
 	}
+	if thirdPartyPTP && sessionLatency < ntpPlayoutLatencyFloor {
+		dbg("[SETUP] raising third-party PTP latency to %v", ntpPlayoutLatencyFloor)
+		sessionLatency = ntpPlayoutLatencyFloor
+	}
 
 	// Apple's sender prepares the receiver with a control-only SETUP before it
 	// creates media streams. Older protocol implementations can explicitly
 	// reject that control shape; in that case, make exactly one transition to
 	// the legacy media-first sequence. No receiver identity or unrelated feature
 	// bit is used to infer SETUP ordering.
-	sessionFirstSetup := true
+	sessionFirstSetup := !thirdPartyPTP
 
 	audioStreamConnectionID := int64(time.Now().UnixNano() & 0x7FFFFFFFFFFFFFFF)
 	selectedAudioCodec := policy.audioCodec
@@ -559,6 +580,13 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
 			return nil
 		}
 		if err := clock.configureFromSetup(response, headers, receivedAt); err != nil {
+			if thirdPartyPTP {
+				// PR #31 keeps streaming when third-party receivers advertise PTPInfo
+				// but omit Apple's ClockID/private timestamp headers. Frame and audio
+				// fallback paths below use its app-relative time base and timeline zero.
+				dbg("[PTP] %v; using legacy local timestamps", err)
+				return nil
+			}
 			if policy.permitsLocalPTPClock() {
 				if fallbackErr := clock.configureFromLocalClock(); fallbackErr == nil {
 					dbg("[PTP] %v; using local boot time on receiver timeline 0x%016x", err, clock.identity())
@@ -570,12 +598,19 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
 		return nil
 	}
 
-	dbg("[SETUP] phase 1 (control): preparing media session")
-	controlPlist := setupRequest.controlPlist()
-	if policy.fairPlayOnControl() {
-		addFairPlayRootFields(controlPlist, c.FpEkey, c.fpIV, true)
+	var controlResp map[string]interface{}
+	var controlHeaders map[string]string
+	var receivedAt time.Time
+	if sessionFirstSetup {
+		dbg("[SETUP] phase 1 (control): preparing media session")
+		controlPlist := setupRequest.controlPlist()
+		if policy.fairPlayOnControl() {
+			addFairPlayRootFields(controlPlist, c.FpEkey, c.fpIV, true)
+		}
+		controlResp, controlHeaders, receivedAt, err = sendSetup(audioURI, "control", controlPlist)
+	} else {
+		dbg("[SETUP] third-party PTP profile: using media-first SETUP")
 	}
-	controlResp, controlHeaders, receivedAt, err := sendSetup(audioURI, "control", controlPlist)
 	videoPrepared := false
 	prepareVideo := func(info *ReceiverInfo) error {
 		if videoPrepared {
@@ -616,7 +651,9 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
 		}
 		return nil, fmt.Errorf("%s GET /info fallback: %w", phase, refreshErr)
 	}
-	if err != nil {
+	if !sessionFirstSetup {
+		// Continue directly to the audio SETUP below.
+	} else if err != nil {
 		if !setupOrderRejected(err) {
 			return nil, err
 		}
@@ -905,18 +942,19 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
 	}
 
 	session := &MirrorSession{
-		client:         c,
-		dataConn:       dataConn,
-		eventConn:      receiverEventConn,
-		cancel:         cancelSession,
-		DataPort:       dataPort,
-		firstFrameSent: make(chan struct{}),
-		noAudio:        cfg.NoAudio,
-		sessionURI:     audioURI,
-		timingConn:     timingConn,
-		timestampBias:  sessionLatency,
-		timingProtocol: timingProtocol,
-		mediaClock:     clock,
+		client:           c,
+		dataConn:         dataConn,
+		eventConn:        receiverEventConn,
+		cancel:           cancelSession,
+		DataPort:         dataPort,
+		firstFrameSent:   make(chan struct{}),
+		noAudio:          cfg.NoAudio,
+		sessionURI:       audioURI,
+		timingConn:       timingConn,
+		timestampBias:    sessionLatency,
+		timingProtocol:   timingProtocol,
+		mediaClock:       clock,
+		legacyLocalClock: thirdPartyPTP,
 	}
 
 	// Set up the video cipher. Encrypted pair-verify uses ChaCha20-Poly1305 with
@@ -2184,6 +2222,9 @@ func (s *MirrorSession) frameTimeNow() (timestamp, timelineID uint64) {
 			return s.monotonicFrameTime(timestamp), timelineID
 		}
 	}
+	if s.legacyLocalClock {
+		return s.monotonicFrameTime(compactTimestamp(time.Since(appStartTime) + bias)), 0
+	}
 	return s.monotonicFrameTime(ntpTimeWithBias(bias)), 0
 }
 
@@ -2203,9 +2244,30 @@ func (s *MirrorSession) audioClockNow() (timestamp, timelineID uint64) {
 			return timestamp, timelineID
 		}
 	}
+	if s.legacyLocalClock {
+		return ntpAppTimestamp(), 0
+	}
 	return ntpBootTimestamp(), 0
 }
 
+// audioTimingProtocol preserves PR #31's third-party fallback: a receiver that
+// advertises PTP but omits ClockID cannot consume a PTP TimeAnnounce. Use the
+// legacy 20-byte NTP mapping while video continues with timeline zero.
+func (s *MirrorSession) audioTimingProtocol(timelineID uint64) string {
+	if s.legacyLocalClock && timelineID == 0 {
+		return timingProtocolNTP
+	}
+	return s.timingProtocol
+}
+
+func ntpAppTimestamp() uint64 {
+	d := time.Since(appStartTime)
+	sec := uint64(d/time.Second) + secondsFrom1900To1970
+	nsecFrac := uint64(d % time.Second)
+	frac := (nsecFrac << 32) / uint64(time.Second)
+	return (sec << 32) | frac
+}
+
 func ntpTimeWithBias(bias time.Duration) uint64 {
 	if bias < 5*time.Millisecond {
 		bias = 5 * time.Millisecond
diff --git a/internal/airplay/receiver_server_test.go b/internal/airplay/receiver_server_test.go
index 007edfc..6a3e021 100644
--- a/internal/airplay/receiver_server_test.go
+++ b/internal/airplay/receiver_server_test.go
@@ -28,7 +28,7 @@ func TestReceiverServerEndToEndProfiles(t *testing.T) {
 	}{
 		{name: "modern Apple HAP accepts control-first PTP", profile: ReceiverProfileModern, wantEncrypted: true, wantSetups: 3, wantFairPlay: 2, wantEvents: 1},
 		{name: "Roku raw negotiates media-first receiver-initiated NTP", profile: ReceiverProfileRoku, wantSetups: 3, wantTiming: 3, wantEvents: 1},
-		{name: "LG HAP negotiates media-first PTP", profile: ReceiverProfileLG, wantEncrypted: true, wantSetups: 3, wantEvents: 1},
+		{name: "LG HAP uses media-first PTP", profile: ReceiverProfileLG, wantEncrypted: true, wantSetups: 2, wantEvents: 1},
 		{name: "AppleTV3 raw negotiates media-first receiver-initiated NTP", profile: ReceiverProfileAppleTV3, wantSetups: 3, wantFairPlay: 2, wantTiming: 3, wantEvents: 1},
 		{name: "UxPlay legacy negotiates media-first without eventPort", profile: ReceiverProfileUxPlay, wantSetups: 3, wantFairPlay: 2, wantTiming: 3},
 		{name: "AirServer raw control-first with descriptor retry", profile: ReceiverProfileAirServer, wantSetups: 4, wantFairPlay: 2, wantTiming: 3, wantEvents: 1},
-- 
2.55.0

@3rd3
3rd3 marked this pull request as draft August 28, 2026 16:58
@3rd3

3rd3 commented Aug 31, 2026

Copy link
Copy Markdown
Author

@omarroth I let Claude attempt to unify the observed behaviors among Roku, LG and Samsung devices (along with the video sink bug) based on the current dev branch. This is untested, but maybe this helps.

Commits 1–5 are line-anchored against the files and can be applied with normal fuzz; commits 6–7 restructure setupMirrorSession and are given as boundary hunks plus an explicit description of what moves, since a mechanical diff of a 340-line reindent isn't reviewable anyway.


1/7 — attach the video sink before broadcast capture starts

cmd/doubletake: attach the video sink before capture starts
 
BroadcastCapture began producing before its sink existed, so the encoder's
initial SPS/PPS/IDR was discarded and StreamFrames waited for OpenH264's next
keyframe at the four-second GOP boundary. Attach the sink before Run so the
first keyframe stays queued.
 
No protocol behaviour changes.
--- a/cmd/doubletake/main.go
+++ b/cmd/doubletake/main.go
@@ -365,6 +365,7 @@ func main() {
 
 	var capture *airplay.ScreenCapture
 	var broadcast *airplay.BroadcastCapture
+	var videoSink *airplay.BroadcastSink
 	var broadcastDone chan error
 	startedWidth, startedHeight := -1, -1
 	prepareVideo := func(width, height int) error {
@@ -380,6 +381,10 @@ func main() {
 		}
 		startedWidth, startedHeight = width, height
 		broadcast = airplay.NewBroadcastCapture(capture)
+		// Attach before Run starts so the initial SPS/PPS/IDR remains queued for
+		// StreamFrames. Attaching after audio setup can miss that first keyframe
+		// and wait for OpenH264's next four-second GOP boundary.
+		videoSink = broadcast.AddSink()
 		broadcastDone = make(chan error, 1)
 		go func(active *airplay.BroadcastCapture, done chan<- error) {
 			done <- active.Run()
@@ -389,6 +394,9 @@ func main() {
 	}
 	defer func() {
 		capturePreparation.Close()
+		if videoSink != nil {
+			videoSink.Close()
+		}
 		if capture != nil {
 			capture.Stop()
 			<-broadcastDone
@@ -441,8 +449,6 @@ func main() {
 		log.Println("audio disabled (receiver did not provide audio ports)")
 	}
 
-	videoSink := broadcast.AddSink()
-	defer videoSink.Close()
 	if err := session.StreamFrames(ctx, videoSink.AsCapture(), 0*time.Second); err != nil && ctx.Err() == nil {
 		log.Fatalf("streaming error: %v", err)
 	}

internal/daemon/daemon.go's getOrStartCaptureGroup has the same structure — worth checking whether it needs the mirrored fix.


2/7 — record advertised PTP separately from selected timing

airplay: record advertised PTP separately from selected timing
 
"The receiver claims PTP" and "this session uses PTP" are different facts, and
later commits need to degrade the second without losing the first. Record the
advertisement so logs and fixtures can distinguish a receiver that never
offered PTP from one that offered it and did not honour it.
 
No behaviour change.
--- a/internal/airplay/compatibility.go
+++ b/internal/airplay/compatibility.go
@@ -23,6 +23,10 @@
 type receiverCompatibility struct {
 	timing           string
+	// ptpAdvertised records that the receiver claims a PTP session. It stays
+	// true after any runtime downgrade, so "never offered PTP" and "offered PTP
+	// and did not honour it" remain distinguishable.
+	ptpAdvertised    bool
 	audioSecurity    audioSecurityMode
 	audioConnections audioConnectionLayout
 	audioCodec       AudioCodec
 	fairPlayRoots    fairPlayRootPlacement
 }
@@ -84,6 +88,7 @@ func compatibilityForReceiver(info *ReceiverInfo, encrypted, audioEnabled bool)
 	// exception whose feature 41 advertisement does not yield a working PTP
 	// mirroring session.
+	policy.ptpAdvertised = info.HasFeature(featurePTP)
 	if encrypted && info.HasFeature(featurePTP) && supportsPTPSourceVersion(info.SourceVersion) {
 		policy.timing = timingProtocolPTP
 	}

3/7 — resolve the PTP clock anchor from the SETUP response

airplay: resolve the PTP clock anchor from the SETUP response
 
A receiver that advertises PTP, accepts the session descriptor and then omits
timingPeerInfo.ClockID currently fails the whole session: configureFromSetup
rejects it and configureFromLocalClock cannot proceed without a ClockID.
 
Refusing to invent a ClockID is right, but "receiver ClockID" and "usable
timeline" are not the same thing. Timeline zero already means "no timeline is
named" -- it is the value frame headers carry on NTP sessions -- and the
20-byte NTP TimeAnnounce has no timeline field at all. A receiver that names no
timeline therefore leaves the sender as grandmaster on timeline zero, and
nothing false is asserted on the wire.
 
Replace the two-state clock with a ladder over what the response contained:
 
  ClockID + anchor headers -> follow the receiver's clock
  ClockID, no headers      -> receiver timeline, locally anchored (unchanged)
  no ClockID               -> sender is grandmaster, timeline zero
  malformed response       -> still fails
 
Grandmaster video reuses the sender's boot clock, which is byte-for-byte the
domain ntpTimeWithBias already produces for NTP sessions, so frameTimeAt needs
no change. Audio must use the timeline-free announce encoding, because a PTP
TimeAnnounce structurally requires a timeline ID that does not exist.
 
Two mid-session hazards fall out of the same distinction and are fixed here:
/feedback must not reanchor a grandmaster session into the receiver's clock
domain, and a late event-channel ClockID must not switch masters mid-stream.
The anchor is chosen once per session.
--- a/internal/airplay/mirror.go
+++ b/internal/airplay/mirror.go
@@ -33,6 +33,36 @@ const (
 	legacyAirPlaySourceVersion = "280.33"
 	modernAirPlaySourceVersion = "980.71.1"
 )
 
+// clockAnchor records where an accepted PTP session's media timeline actually
+// came from. It is derived from SETUP response content, never from receiver
+// identity, and it is the only input the audio announce encoding needs.
+type clockAnchor uint8
+
+const (
+	// clockAnchorNone means no PTP timeline was established.
+	clockAnchorNone clockAnchor = iota
+	// clockAnchorReceiverClock follows the receiver's timeline, anchored to the
+	// receiver's own request/processing timestamps.
+	clockAnchorReceiverClock
+	// clockAnchorReceiverTimeline follows the receiver's timeline but anchors it
+	// locally, because Apple's private clock headers were absent.
+	clockAnchorReceiverTimeline
+	// clockAnchorLocalMaster means the receiver accepted the PTP session and
+	// named no timeline of its own. The sender owns the timeline, so there is no
+	// receiver ClockID to place in a frame header or in a PTP TimeAnnounce, and
+	// timeline zero carries its established "no named timeline" meaning.
+	clockAnchorLocalMaster
+)
+
+var (
+	// errMissingReceiverTimeline is a capability gap, not a malformed response:
+	// the receiver accepted PTP without naming a timeline.
+	errMissingReceiverTimeline = errors.New("SETUP response omitted timingPeerInfo.ClockID")
+	// errMissingClockAnchor means a timeline was named but its anchor headers
+	// were absent, so the anchor must be taken locally.
+	errMissingClockAnchor = errors.New("SETUP response omitted the receiver clock anchor headers")
+)
+
 // mediaClock maps local monotonic time onto the receiver's PTP timeline. The
 // receiver's X-Apple-RequestReceivedTimestamp is in the same boot-relative
 // domain as its PTP Follow_Up timestamps, so no local PTP stack is required.
@@ -47,7 +77,7 @@ func (c *mediaClock) configureFromSetup(response map[string]interface{}, headers
 	peer, _ := response["timingPeerInfo"].(map[string]interface{})
 	timelineID := plistUint64(peer["ClockID"])
 	if timelineID == 0 {
-		return fmt.Errorf("SETUP response omitted timingPeerInfo.ClockID")
+		return errMissingReceiverTimeline
 	}
 
 	anchorTimestamp, receivedMillis, processingMillis, err := receiverClockTimestamp(headers)
@@ -58,7 +88,7 @@ func (c *mediaClock) configureFromSetup(response map[string]interface{}, headers
 		c.mu.Lock()
 		c.timelineID = timelineID
 		c.mu.Unlock()
-		return err
+		return fmt.Errorf("%w: %v", errMissingClockAnchor, err)
 	}
 
 	c.mu.Lock()
@@ -75,24 +105,60 @@
-// configureFromLocalClock anchors a PTP timeline to the sender's boot clock.
-// Some third-party PTP receivers return ClockID but omit Apple's private clock
-// headers. A ClockID remains mandatory: inventing one would describe a timeline
-// the receiver has never advertised and would make both audio and video invalid.
-func (c *mediaClock) configureFromLocalClock() error {
-	anchorLocal := time.Now()
-	anchorTimestamp := compactTimestamp(bootRelativeNow())
-
-	c.mu.Lock()
-	defer c.mu.Unlock()
-	if c.timelineID == 0 {
-		return fmt.Errorf("cannot configure local PTP clock without receiver ClockID")
-	}
-	c.anchorLocal = anchorLocal
-	c.anchorTimestamp = anchorTimestamp
-	return nil
-}
+// anchorLocally starts the media timeline from the sender's boot clock, which is
+// the same base ntpTimeWithBias already uses successfully for NTP video. It
+// serves both the case where the receiver names a timeline but omits Apple's
+// clock headers, and the case where it names no timeline at all. In the second
+// case timelineID deliberately stays zero: nothing fabricates a timeline the
+// receiver never advertised, and zero is the value frame headers already carry
+// when no timeline is named.
+func (c *mediaClock) anchorLocally() {
+	anchorLocal := time.Now()
+	anchorTimestamp := compactTimestamp(bootRelativeNow())
+
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.anchorLocal = anchorLocal
+	c.anchorTimestamp = anchorTimestamp
+}
+
+// hasReceiverTimeline reports whether the receiver named a timeline this session
+// can address. A PTP TimeAnnounce, a nonzero header timeline, and adopting the
+// receiver's clock samples are all valid only when it is true.
+func (c *mediaClock) hasReceiverTimeline() bool {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.timelineID != 0
+}
+
+// resolvePTPAnchor is the anchoring ladder. Every branch is selected by what the
+// SETUP response contained; a response that is malformed rather than merely
+// incomplete still fails the session.
+func resolvePTPAnchor(clock *mediaClock, response map[string]interface{}, headers map[string]string, receivedAt time.Time) (clockAnchor, error) {
+	err := clock.configureFromSetup(response, headers, receivedAt)
+	switch {
+	case err == nil:
+		return clockAnchorReceiverClock, nil
+	case errors.Is(err, errMissingClockAnchor):
+		clock.anchorLocally()
+		dbg("[PTP] %v; anchoring receiver timeline 0x%016x locally", err, clock.identity())
+		return clockAnchorReceiverTimeline, nil
+	case errors.Is(err, errMissingReceiverTimeline):
+		clock.anchorLocally()
+		dbg("[PTP] %v; sender is grandmaster on timeline 0", err)
+		return clockAnchorLocalMaster, nil
+	default:
+		return clockAnchorNone, err
+	}
+}
 
 func (c *mediaClock) identity() uint64 {
@@ -142,10 +208,19 @@ func (c *mediaClock) updateTimingPeerInfo(peer map[string]interface{}, receivedA
 	c.mu.Lock()
 	previousTimeline := c.timelineID
+	// A session anchored as grandmaster cannot adopt a receiver timeline later:
+	// its anchor is in the sender's own epoch, so the receiver's timestamps and
+	// ours have no established relationship, and the audio announce encoding
+	// would change mid-stream. Keep one coherent domain per session.
+	if previousTimeline == 0 && !c.anchorLocal.IsZero() {
+		c.mu.Unlock()
+		dbg("[PTP] ignoring late timeline 0x%016x on a grandmaster session", timelineID)
+		return nil
+	}
 	if !c.anchorLocal.IsZero() && !receivedAt.Before(c.anchorLocal) {
@@ -165,7 +240,7 @@ func (c *mediaClock) now(bias time.Duration) (timestamp, timelineID uint64, ok b
 	timelineID = c.timelineID
 	c.mu.RUnlock()
-	if anchorLocal.IsZero() || timelineID == 0 {
+	if anchorLocal.IsZero() {
 		return 0, 0, false
 	}
@@ -179,7 +254,7 @@ func (c *mediaClock) at(local time.Time, bias time.Duration) (timestamp, timelin
 	timelineID = c.timelineID
 	c.mu.RUnlock()
-	if local.IsZero() || anchorLocal.IsZero() || timelineID == 0 {
+	if local.IsZero() || anchorLocal.IsZero() {
 		return 0, 0, false
 	}
@@ -384,6 +459,7 @@ func (c *AirPlayClient) setupMirrorSession(...)
 	var clock *mediaClock
+	sessionAnchor := clockAnchorNone
 	if timingProtocol == timingProtocolPTP {
 		clock = &mediaClock{}
 	}
@@ -611,17 +687,19 @@
 	configurePTPClock := func(response map[string]interface{}, headers map[string]string, receivedAt time.Time) error {
 		if timingProtocol != timingProtocolPTP {
 			return nil
 		}
-		if err := clock.configureFromSetup(response, headers, receivedAt); err != nil {
-			if policy.permitsLocalPTPClock() {
-				if fallbackErr := clock.configureFromLocalClock(); fallbackErr == nil {
-					dbg("[PTP] %v; using local boot time on receiver timeline 0x%016x", err, clock.identity())
-					return nil
-				}
-			}
-			return fmt.Errorf("configure PTP media clock: %w", err)
-		}
+		anchor, err := resolvePTPAnchor(clock, response, headers, receivedAt)
+		if err != nil {
+			return fmt.Errorf("configure PTP media clock: %w", err)
+		}
+		sessionAnchor = anchor
 		return nil
 	}

Add the resolved-profile line just before session := &MirrorSession{ so every future report is diagnosable without a capture:

	dbg("[PROFILE] timing=%s anchor=%d receiverTimeline=%t version=%s audio=%s video=%s lead=%v/%v",
		timingProtocol, sessionAnchor, clock != nil && clock.hasReceiverTimeline(),
		sourceVersion, audioLayoutName(audioLayout), videoCodec, latencies.video, latencies.audio)
@@ -2040,7 +2130,11 @@ func (s *MirrorSession) feedbackLoop(ctx context.Context) {
-		if s.mediaClock != nil {
+		// Only a session that follows a receiver-named timeline may adopt the
+		// receiver's clock samples. A grandmaster session owns its own epoch;
+		// reanchoring it to receiver time would move video into a different
+		// domain than the announce it already published.
+		if s.mediaClock != nil && s.mediaClock.hasReceiverTimeline() {
 			if err := s.mediaClock.reanchor(headers, receivedAt); err != nil {
 				dbg("[PTP] feedback clock update ignored: %v", err)
 			}
 		}
@@ -2445,10 +2539,25 @@ func (s *MirrorSession) audioClockNow() (timestamp, timelineID uint64) {
 func (s *MirrorSession) audioClockNow() (timestamp, timelineID uint64) {
-	if s.mediaClock != nil {
+	if s.mediaClock != nil && s.mediaClock.hasReceiverTimeline() {
 		if timestamp, timelineID, ok := s.mediaClock.now(0); ok {
 			return timestamp, timelineID
 		}
 	}
 	return ntpBootTimestamp(), 0
 }
+
+// audioTimingProtocol selects the announce encoding. A PTP TimeAnnounce carries
+// a timeline ID; when the sender is grandmaster there is none to carry, so the
+// timeline-free 20-byte mapping is the only well-formed encoding. Video is
+// unaffected: its header timeline field already has a defined zero value.
+func (s *MirrorSession) audioTimingProtocol() string {
+	if s.timingProtocol == timingProtocolPTP &&
+		(s.mediaClock == nil || !s.mediaClock.hasReceiverTimeline()) {
+		return timingProtocolNTP
+	}
+	return s.timingProtocol
+}
--- a/internal/airplay/compatibility.go
+++ b/internal/airplay/compatibility.go
@@ -164,10 +169,6 @@ func (p receiverCompatibility) sourceVersion() string {
-func (p receiverCompatibility) permitsLocalPTPClock() bool {
-	return p.timing == timingProtocolPTP
-}
-
 func (p receiverCompatibility) fairPlayOnControl() bool {
--- a/internal/airplay/audio.go
+++ b/internal/airplay/audio.go
@@ -982,7 +982,7 @@ func (s *MirrorSession) audioClockAt(local time.Time) (timestamp, timelineID uin
 	if local.IsZero() {
 		return s.audioClockNow()
 	}
-	if s.mediaClock != nil {
+	if s.mediaClock != nil && s.mediaClock.hasReceiverTimeline() {
 		if timestamp, timelineID, ok := s.mediaClock.at(local, 0); ok {
 			return timestamp, timelineID
 		}
 	}
@@ -1168,7 +1168,7 @@ videoReady:
-	if err := audioStream.sendSyncPacketAt(s.timingProtocol, clockNow, timelineID, firstFrameRTP, true); err != nil {
+	if err := audioStream.sendSyncPacketAt(s.audioTimingProtocol(), clockNow, timelineID, firstFrameRTP, true); err != nil {
 		return fmt.Errorf("audio initial clock mapping: %w", err)
 	}
@@ -1187,7 +1187,7 @@
 				if !timestampedAudio {
 					clockNow, timelineID := s.audioClockNow()
-					if err := audioStream.sendSyncPacket(s.timingProtocol, clockNow, timelineID, false); err != nil {
+					if err := audioStream.sendSyncPacket(s.audioTimingProtocol(), clockNow, timelineID, false); err != nil {
 						dbg("[AUDIO] sync error: %v", err)
 					}
 					continue
@@ -1199,7 +1199,7 @@
 				clockNow, timelineID := s.audioClockAt(announcedAt)
-				err := audioStream.sendSyncPacketAt(s.timingProtocol, clockNow, timelineID, rtpNow, false)
+				err := audioStream.sendSyncPacketAt(s.audioTimingProtocol(), clockNow, timelineID, rtpNow, false)
 				announceMu.Unlock()
@@ -1320,7 +1320,7 @@
 					clockNow, timelineID := s.audioClockAt(framePTS)
-					err := audioStream.sendSyncPacketAt(s.timingProtocol, clockNow, timelineID, frameRTP, true)
+					err := audioStream.sendSyncPacketAt(s.audioTimingProtocol(), clockNow, timelineID, frameRTP, true)
 					announceMu.Unlock()

sendSyncPacketAt's existing timelineID == 0 guard stays and becomes unreachable-by-construction — keep it as the invariant check.

Tests:

--- a/internal/airplay/mirror_clock_test.go
+++ b/internal/airplay/mirror_clock_test.go
@@ -390,12 +390,53 @@
-func TestMediaClockRequiresReceiverClockIdentity(t *testing.T) {
-	clock := &mediaClock{}
-	err := clock.configureFromSetup(nil, map[string]string{
-		"x-apple-requestreceivedtimestamp": "1",
-	}, time.Now())
-	if err == nil {
-		t.Fatal("configureFromSetup succeeded without timingPeerInfo.ClockID")
-	}
-	if err := clock.configureFromLocalClock(); err == nil {
-		t.Fatal("local PTP fallback succeeded without a receiver ClockID")
-	}
-}
+func TestPTPAnchorBecomesGrandmasterWithoutReceiverTimeline(t *testing.T) {
+	clock := &mediaClock{}
+	anchor, err := resolvePTPAnchor(clock, nil, map[string]string{
+		"x-apple-requestreceivedtimestamp": "1",
+	}, time.Now())
+	if err != nil {
+		t.Fatalf("resolvePTPAnchor: %v", err)
+	}
+	if anchor != clockAnchorLocalMaster {
+		t.Fatalf("anchor = %d, want grandmaster", anchor)
+	}
+	if clock.hasReceiverTimeline() || clock.identity() != 0 {
+		t.Fatalf("grandmaster claimed timeline 0x%016x, want none", clock.identity())
+	}
+	session := &MirrorSession{mediaClock: clock, timingProtocol: timingProtocolPTP, timestampBias: 5 * time.Millisecond}
+	if _, timeline := session.frameTimeNow(); timeline != 0 {
+		t.Fatalf("grandmaster video timeline = 0x%016x, want 0", timeline)
+	}
+	if got := session.audioTimingProtocol(); got != timingProtocolNTP {
+		t.Fatalf("grandmaster audio announce = %q, want NTP", got)
+	}
+	packet := captureAudioSyncPacket(t, &AudioStream{}, session.audioTimingProtocol(), 1, 0, false)
+	if len(packet) != 20 || packet[1] != audioSyncPayloadTypeNTP {
+		t.Fatalf("announce = len %d type 0x%02x, want timeline-free len 20 type 0x%02x",
+			len(packet), packet[1], audioSyncPayloadTypeNTP)
+	}
+}
+
+func TestPTPAnchorRejectsMalformedResponse(t *testing.T) {
+	clock := &mediaClock{}
+	if _, err := resolvePTPAnchor(clock, map[string]interface{}{
+		"timingPeerInfo": map[string]interface{}{"ClockID": uint64(1)},
+	}, map[string]string{
+		"x-apple-requestreceivedtimestamp": "1",
+		"x-apple-processingtime":           "not-a-number",
+	}, time.Now()); err != nil {
+		t.Fatalf("a named timeline with unusable headers must anchor locally, got %v", err)
+	}
+	if !clock.hasReceiverTimeline() {
+		t.Fatal("named timeline was discarded")
+	}
+}
+
+func TestGrandmasterSessionRefusesLateReceiverTimeline(t *testing.T) {
+	clock := &mediaClock{}
+	if _, err := resolvePTPAnchor(clock, nil, nil, time.Now()); err != nil {
+		t.Fatalf("resolvePTPAnchor: %v", err)
+	}
+	if err := clock.updateTimingPeerInfo(map[string]interface{}{"ClockID": uint64(7)}, time.Now()); err != nil {
+		t.Fatalf("late timeline update: %v", err)
+	}
+	if clock.hasReceiverTimeline() {
+		t.Fatal("grandmaster session switched masters mid-stream")
+	}
+}

Also rename TestThirdPartyPTPLocalClockFallbackKeepsPTPAudioAndVideoTimelineTestReceiverTimelineWithoutClockHeadersKeepsPTPAnnounce and swap configureFromLocalClock() for anchorLocally() (no error return). It stays valid and now covers clockAnchorReceiverTimeline.


4/7 — make the presets reproduce the field failures

airplay: make the LG preset omit the receiver clock identity
 
The lg preset supplied timingPeerInfo.ClockID and withheld only the private
clock headers, so it exercised the locally-anchored receiver-timeline path and
never the grandmaster path. The receivers it models omit ClockID entirely,
which is why a regression in that path went unnoticed.
 
Add a samsung preset that reaches the same resolved wire profile through a
different vendor, feature mask and implementation version. Its only purpose is
to fail if any decision is re-derived from receiver identity: the two masks
differ in exactly one bit (42), and the samsung mask is identical to the roku
preset's.
--- a/internal/airplay/receiver_server.go
+++ b/internal/airplay/receiver_server.go
@@ -34,6 +34,7 @@ const (
 	ReceiverProfileRoku      ReceiverProfile = "roku"
 	ReceiverProfileLG        ReceiverProfile = "lg"
+	ReceiverProfileSamsung   ReceiverProfile = "samsung"
 	ReceiverProfileAppleTV3  ReceiverProfile = "appletv3"
 	ReceiverProfileUxPlay    ReceiverProfile = "uxplay"
 	ReceiverProfileAirServer ReceiverProfile = "airserver"
@@ -358,18 +359,42 @@ func receiverProfile(profile ReceiverProfile) (receiverProfileSpec, error) {
 	case ReceiverProfileLG:
 		return receiverProfileSpec{
 			name:                    "doubletake LG test receiver",
 			manufacturer:            "LG Electronics",
 			model:                   "75UP75009LC",
 			sourceVersion:           "377.25.06",
 			features:                uint64(0x038bcb46007f8ad0),
 			pairing:                 receiverPairingLegacyHAP,
 			setupOrder:              receiverSetupMediaFirst,
 			timingProtocol:          timingProtocolPTP,
 			advertisePTPInfo:        true,
-			providePTPClockIdentity: true,
+			// This receiver accepts the PTP session descriptor and supplies
+			// neither timingPeerInfo.ClockID nor Apple's private clock headers,
+			// which is the grandmaster case.
+			providePTPClockIdentity: false,
+			providePTPClockHeaders:  false,
 			audioCodec:              AudioCodecALAC,
 			supportedScreenFormats:  0x40000,
 			audioSHKWithHAP:         true,
 			displayWidth:            1920,
 			displayHeight:           1080,
+		}, nil
+	case ReceiverProfileSamsung:
+		// Same resolved wire profile as the lg preset, reached through a
+		// different vendor, feature mask and implementation version.
+		return receiverProfileSpec{
+			name:                    "doubletake Samsung test receiver",
+			manufacturer:            "Samsung",
+			model:                   "UTU7000_KA",
+			sourceVersion:           "366.24.06",
+			features:                uint64(0x038bcf46007f8ad0),
+			pairing:                 receiverPairingLegacyHAP,
+			setupOrder:              receiverSetupMediaFirst,
+			timingProtocol:          timingProtocolPTP,
+			advertisePTPInfo:        true,
+			providePTPClockIdentity: false,
+			providePTPClockHeaders:  false,
+			audioCodec:              AudioCodecALAC,
+			supportedScreenFormats:  0x40000,
+			audioSHKWithHAP:         true,
+			displayWidth:            3840,
+			displayHeight:           2160,
 		}, nil
--- a/internal/airplay/receiver_server_test.go
+++ b/internal/airplay/receiver_server_test.go
@@ -32,6 +32,7 @@ func TestReceiverServerEndToEndProfiles(t *testing.T) {
-		{name: "LG HAP negotiates media-first PTP", profile: ReceiverProfileLG, wantEncrypted: true, wantSetups: 3, wantEvents: 1},
+		{name: "LG HAP resolves to grandmaster PTP", profile: ReceiverProfileLG, wantEncrypted: true, wantSetups: 3, wantEvents: 1, wantGrandmaster: true},
+		{name: "Samsung HAP resolves identically to LG", profile: ReceiverProfileSamsung, wantEncrypted: true, wantSetups: 3, wantEvents: 1, wantGrandmaster: true},

with a wantGrandmaster bool field and, after SetupMirror succeeds:

			if test.wantGrandmaster {
				if session.mediaClock == nil || session.mediaClock.hasReceiverTimeline() {
					t.Fatalf("resolved timeline = 0x%016x, want grandmaster", session.mediaClock.identity())
				}
				if got := session.audioTimingProtocol(); got != timingProtocolNTP {
					t.Fatalf("grandmaster audio announce = %q, want NTP", got)
				}
			}

wantSetups stays 3: the preset is receiverSetupMediaFirst, so the control SETUP is rejected and counted, then audio and video succeed. Also update the lg row in TestReceiverProfilePresets (ptpClockIdentityfalse), add the samsung row there, and add a samsung line to TestReceiverServerAdvertisesCapabilityAxes.


5/7 — bound SETUP responses on PTP sessions

airplay: bound SETUP responses on PTP sessions
 
rtspRequest has no deadline parameter, so SETUP inherits the 30-second default
read deadline. A receiver that accepts the request and then stalls for twenty
seconds before answering therefore looks like a slow success rather than a
rejection, and the sender cannot renegotiate cheaply.
 
Add rtspRequestWithTimeout, mirroring the existing httpRequest plumbing, and
classify deadline expiry as a stall. The deadline is not uniform: on an NTP
session the receiver probes the sender's timing port during SETUP, so a host
firewall legitimately produces a long stall and the existing firewall
diagnostic must stay reachable. A PTP session never waits on that socket, so
only PTP sessions take the short deadline.
 
A stall is evidence about the session dialect, never about the descriptor
shape, so it must not trigger the alternate-descriptor retry.
--- a/internal/airplay/client.go
+++ b/internal/airplay/client.go
@@ -856,17 +856,26 @@
 // rtspRequest sends an RTSP/1.0 request (used after pairing for mirror setup).
 func (c *AirPlayClient) rtspRequest(method, uri, contentType string, body []byte, extraHeaders map[string]string) ([]byte, map[string]string, error) {
+	return c.rtspRequestWithTimeout(method, uri, contentType, body, extraHeaders, 0)
+}
+
+// rtspRequestWithTimeout is rtspRequest with an explicit response deadline, so
+// session negotiation can classify a stalled receiver within a bounded time
+// instead of occupying the default read deadline. A non-positive timeout keeps
+// the default.
+func (c *AirPlayClient) rtspRequestWithTimeout(method, uri, contentType string, body []byte, extraHeaders map[string]string, timeout time.Duration) ([]byte, map[string]string, error) {
 	c.mu.Lock()
 	defer c.mu.Unlock()
 
 	if authHdr, ok := c.preemptiveAuthHeader(method, uri); ok {
 		dbg("[RTSP] authenticating %s %s up front from the cached challenge", method, uri)
 		extraHeaders = withHeader(extraHeaders, "Authorization", authHdr)
 	}
 
-	respBody, respHeaders, err := c.rtspRequestOnce(method, uri, contentType, body, extraHeaders)
+	respBody, respHeaders, err := c.rtspRequestOnce(method, uri, contentType, body, extraHeaders, timeout)
 	var authHdr string
 	var retry bool
 	authHdr, retry, err = c.digestRetryHeader(method, uri, respHeaders, err)
 	if retry {
 		dbg("[RTSP] 401 digest challenge on %s %s, retrying with credentials", method, uri)
-		respBody, respHeaders, err = c.rtspRequestOnce(method, uri, contentType, body, withHeader(extraHeaders, "Authorization", authHdr))
+		respBody, respHeaders, err = c.rtspRequestOnce(method, uri, contentType, body, withHeader(extraHeaders, "Authorization", authHdr), timeout)
 		c.logIfAuthRejected(method, uri, err)
 	}
 	return respBody, respHeaders, err
 }
 
-func (c *AirPlayClient) rtspRequestOnce(method, uri, contentType string, body []byte, extraHeaders map[string]string) ([]byte, map[string]string, error) {
+func (c *AirPlayClient) rtspRequestOnce(method, uri, contentType string, body []byte, extraHeaders map[string]string, timeout time.Duration) ([]byte, map[string]string, error) {
@@ -908,7 +917,7 @@
-	respBody, respHeaders, err := c.readHTTPResponse()
+	respBody, respHeaders, err := c.readHTTPResponseWithTimeout(timeout)
--- a/internal/airplay/mirror.go
+++ b/internal/airplay/mirror.go
@@ -13,6 +13,7 @@ import (
 	"math"
 	"net"
+	"os"
 	"sort"
@@ -28,6 +29,12 @@ const (
 	timingProtocolNTP          = "NTP"
 	timingProtocolPTP          = "PTP"
 	sessionInfoFallbackTimeout = 3 * time.Second
+	// ptpSetupResponseTimeout bounds SETUP on a PTP session, where the sender is
+	// not waiting on its own timing socket and a long stall therefore cannot be
+	// the receiver's blocked NTP probe. NTP sessions keep the default deadline
+	// so the firewall diagnostic in sendSetup stays reachable.
+	ptpSetupResponseTimeout = 10 * time.Second
+
@@ -332,6 +339,13 @@
+// setupStalled reports a receiver that accepted the request and then produced no
+// response. It is evidence about the session dialect, never about the descriptor
+// shape: retrying a different descriptor after a stall risks a second commit.
+func setupStalled(err error) bool {
+	return errors.Is(err, os.ErrDeadlineExceeded)
+}
+
 func audioLayoutName(layout audioConnectionLayout) string {
@@ -352,7 +366,7 @@
-func (c *AirPlayClient) requestSetup(uri, phase string, request map[string]interface{}) (map[string]interface{}, map[string]string, time.Time, error) {
+func (c *AirPlayClient) requestSetup(uri, phase string, request map[string]interface{}, timeout time.Duration) (map[string]interface{}, map[string]string, time.Time, error) {
 	body, err := plist.Marshal(request, plist.BinaryFormat)
 	if err != nil {
 		return nil, nil, time.Time{}, fmt.Errorf("marshal %s SETUP: %w", phase, err)
 	}
-	responseBody, headers, err := c.rtspRequest("SETUP", uri, "application/x-apple-binary-plist", body, nil)
+	responseBody, headers, err := c.rtspRequestWithTimeout("SETUP", uri, "application/x-apple-binary-plist", body, nil, timeout)

In setupMirrorSession, derive the deadline and pass it through sendSetup (both c.requestSetup call sites at lines 534 and 551):

	setupTimeout := time.Duration(0)
	if timingProtocol == timingProtocolPTP {
		setupTimeout = ptpSetupResponseTimeout
	}

and order the audio-SETUP error checks so a stall never reaches the descriptor retry:

@@ -807,7 +830,7 @@
-	if err != nil && setupShapeRejected(err) {
+	if err != nil && !setupStalled(err) && setupShapeRejected(err) {
 		alternate := audioLayoutControlPort

6/7 — fall back once to the legacy session dialect

airplay: fall back once to the legacy session dialect
 
A receiver can accept the control-first prepare and then refuse to create a
media stream. The observed signal is a stall or rejection on audio SETUP, which
survives the bounded alternate-descriptor probe; no advertised capability
predicts it. Attributing it to a receiver's model or feature mask would be a
fingerprint, so make it a negotiation outcome instead: if the modern session
dialect cannot create a media stream, tear the session down and restart it once
in the legacy dialect.
 
Ordering and implementation version are one dialect, not two independent
choices. A receiver that rejected the modern prepare parses SETUP the way the
legacy implementation did, so announcing a modern sourceVersion alongside the
legacy media-first sequence is internally inconsistent. sourceVersion therefore
follows the resolved dialect.
 
The retry reuses the paired control connection. Local capture preparation and
measured latency are host properties and are deliberately not reset.
--- a/internal/airplay/compatibility.go
+++ b/internal/airplay/compatibility.go
@@ -20,6 +20,17 @@ const (
 	fairPlayAllRoots
 )
 
+// sessionDialect is the SETUP sequence the sender is currently speaking,
+// together with the implementation version that matches it. It is a negotiation
+// result, never a receiver property: every session opens on
+// sessionDialectModern and may make at most one transition.
+type sessionDialect uint8
+
+const (
+	sessionDialectModern sessionDialect = iota
+	sessionDialectLegacy
+)
+
 type receiverCompatibility struct {
@@ -157,10 +168,20 @@
-func (p receiverCompatibility) sourceVersion() string {
-	if p.audioSecurity == audioSecurityChaCha {
-		return modernAirPlaySourceVersion
-	}
-	return legacyAirPlaySourceVersion
-}
+// sourceVersionForDialect presents the implementation version that matches the
+// SETUP sequence actually being spoken.
+func (p receiverCompatibility) sourceVersionForDialect(dialect sessionDialect) string {
+	if dialect == sessionDialectLegacy {
+		return legacyAirPlaySourceVersion
+	}
+	if p.audioSecurity == audioSecurityChaCha {
+		return modernAirPlaySourceVersion
+	}
+	return legacyAirPlaySourceVersion
+}
+
+func (p receiverCompatibility) sourceVersion() string {
+	return p.sourceVersionForDialect(sessionDialectModern)
+}

Restructure of setupMirrorSession. What moves: lines 627–965 (from dbg("[SETUP] phase 1 (control)…") through the media-first recordSession() block) become the body of

	runSessionAttempt := func(dialect sessionDialect) error { … }

Changes inside the moved body, all mechanical:

  • sessionFirstSetup is deleted; every use becomes dialect == sessionDialectModern.
  • sourceVersion and setupRequest.sourceVersion are re-derived per attempt from policy.sourceVersionForDialect(dialect).
  • audioStreamConnectionID and audioURI are assigned per attempt (moved down from line 478/483), so the retry does not address the torn-down session.
  • the existing single-shot sessionFirstSetup = false transition at line 700 is deleted; that rejection now returns an error and the driver decides.
  • the PTP timingConn.Close() at lines 979–984 moves out of the attempt into the post-loop success path — otherwise a second attempt has no timing socket if the first was PTP.
  • return nil at the end.

Driver, replacing the moved region:

	// Each attempt creates a media session on the same paired control connection.
	// Capture preparation and measured latency are host properties, not session
	// state, and survive the transition.
	resetSessionState := func() {
		if receiverEventConn != nil {
			_ = receiverEventConn.Close()
			receiverEventConn = nil
		}
		if dataConn != nil {
			_ = dataConn.Close()
			dataConn = nil
		}
		receiverEventPort, attemptedEventPort = 0, 0
		audioDataPort, audioControlPort = 0, 0
		audioSetupCommitted = false
		skipRecord = false
		sessionAnchor = clockAnchorNone
		if timingProtocol == timingProtocolPTP {
			clock = &mediaClock{}
		}
	}
 
	dialect := sessionDialectModern
	for {
		err := runSessionAttempt(dialect)
		if err == nil {
			break
		}
		// One transition, one direction. A stall or an explicit protocol
		// rejection is the only evidence used; a transport failure, an
		// authentication challenge, or a generic server error is not.
		if dialect != sessionDialectModern || !(setupStalled(err) || setupOrderRejected(err)) {
			return nil, err
		}
		dbg("[SETUP] modern session dialect could not create a media stream (%v); "+
			"restarting once in the legacy dialect", err)
		if audioSetupCommitted || sessionCreated {
			if _, _, tearErr := c.rtspRequest("TEARDOWN", audioURI, "", nil, nil); tearErr != nil {
				dbg("[SETUP] TEARDOWN before dialect fallback failed: %v", tearErr)
			}
		}
		resetSessionState()
		dialect = sessionDialectLegacy
	}
 
	if timingProtocol == timingProtocolPTP {
		// PTP uses the receiver's fixed 319/320 ports. The first socket was only
		// reserved while allocating consecutive audio control/data ports.
		_ = timingConn.Close()
		timingConn = nil
	}

sessionCreated is a new bool set to true right after the control SETUP is accepted, so the TEARDOWN is skipped when the receiver never created state.

New coverage worth adding: a preset that accepts control-first and then rejects every audio SETUP shape, asserting exactly two attempts, a TEARDOWN between them, and sourceVersion == legacyAirPlaySourceVersion on the second.


7/7 — downgrade PTP to NTP at runtime instead of denylisting a version

airplay: downgrade PTP to NTP at runtime
 
supportsPTPSourceVersion denylists implementation version 377.40.x because a
receiver advertising feature 41 there does not yield a working PTP session.
That is a vendor fingerprint, and mergeAdvertisementIntoReceiverInfo can derive
sourceVersion from the RTSP Server header, so the branch can be decided by a
header a firmware bump may change.
 
With the dialect harness in place, timing becomes a second axis on the same
bounded degradation: if a receiver rejects the SETUP that carries its own
advertised timing protocol, downgrade PTP to NTP once and retry. Each axis
degrades at most once, and a rejection is attributed to the axis that has not
yet been tried, so the dialect transition still happens first.
 
The denylist is then unnecessary and is removed.
--- a/internal/airplay/compatibility.go
+++ b/internal/airplay/compatibility.go
@@ -109,9 +130,11 @@
 func supportsPTPSourceVersion(sourceVersion string) bool {
+	// No implementation-version denylist: a receiver that advertises PTP and
+	// does not honour it is downgraded once by the timing axis in
+	// setupMirrorSession.
 	major, minor, patch, ok := parseSourceVersion(sourceVersion)
-	if !ok || major == 377 && minor == 40 {
+	if !ok {
 		return false
 	}

Driver extension in setupMirrorSession:

	timingDowngraded := falseswitch {
		case dialect == sessionDialectModern && (setupStalled(err) || setupOrderRejected(err)):
			// … dialect fallback as in commit 6 …
		case !timingDowngraded && policy.ptpAdvertised && timingProtocol == timingProtocolPTP && setupOrderRejected(err):
			// The receiver rejected a SETUP carrying the timing protocol it
			// advertised. Keep the resolved dialect and degrade timing once.
			dbg("[SETUP] receiver rejected its advertised PTP session (%v); downgrading to NTP", err)
			timingDowngraded = true
			timingProtocol = timingProtocolNTP
			setupRequest.timingProtocol = timingProtocolNTP
			setupRequest.timingPeerID, setupRequest.timingPeerAddress = "", ""
			clock = nil
			setupTimeout = 0
			go ntpTimingResponder(sessionCtx, timingConn)
			…teardown + resetSessionStatedefault:
			return nil, err
		}

resetSessionState must not reallocate clock once timing is NTP — the if timingProtocol == timingProtocolPTP guard above already handles that. The retry budget becomes at most three attempts (modern/PTP → legacy/PTP → legacy/NTP), each axis moving once and never back.

The check that this is genuinely general: TestReceiverServerEndToEndProfiles/roku must still pass with wantTiming: 3 after the denylist is gone. The roku preset rejects the control-only SETUP (dialect axis), then rejects the media-first SETUP on timingProtocol is "PTP", want "NTP" (timing axis), then succeeds — so wantSetups rises from 3 to 4 for that row. If it passes, the framework demonstrably subsumes the fingerprint.


Two things to verify on hardware, in this order

  1. Commit 3 alone, against the LG. Grandmaster video reuses bootRelativeNow() rather than PR Support third-party HAP TVs that advertise PTP (LG 75UP75009LC) #31's app-relative epoch, and there is no 500 ms floor. If video is correct and audio plays, both of those were incidental and the series stays small. If not, each becomes its own commit with the measurement attached — which is a far easier review than the same value buried in a device predicate.
  2. Commit 7 against a real Roku, since it's the one that changes behaviour for a receiver that currently works.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant