Support third-party HAP TVs that advertise PTP (LG 75UP75009LC) - #31
Support third-party HAP TVs that advertise PTP (LG 75UP75009LC)#313rd3 wants to merge 4 commits into
Conversation
Added PTPInfo key handling and updated ReceiverInfo struct.
Add tests for audio security mode selection.
|
Confirmed to work with the 75UR781C0LK model as well.
So, this likely works with all LG models with webOS 6.0+. |
SummaryCommit
This PR ports the missing semantics from PR #31 onto the current Changes
Five-second startup delayThe delay was unrelated to network setup or PTP overflow.
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
PatchFrom 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 |
|
@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 Commits 1–5 are line-anchored against the files and can be applied with normal fuzz; commits 6–7 restructure 1/7 — attach the video sink before broadcast capture starts--- 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)
}
2/7 — record advertised PTP separately from selected timing--- 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--- 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 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()
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 4/7 — make the presets reproduce the field failures--- 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 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)
}
}
5/7 — bound SETUP responses on PTP sessions--- 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 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 := audioLayoutControlPort6/7 — fall back once to the legacy session dialect--- 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 runSessionAttempt := func(dialect sessionDialect) error { … }Changes inside the moved body, all mechanical:
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
}
New coverage worth adding: a preset that accepts control-first and then rejects every audio SETUP shape, asserting exactly two attempts, a 7/7 — downgrade PTP to NTP at runtime instead of denylisting a version--- 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 timingDowngraded := false
…
switch {
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 + resetSessionState…
default:
return nil, err
}
The check that this is genuinely general: Two things to verify on hardware, in this order
|
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 returned400. 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
PTPInfoand 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
PTPInfofrom/infoand select PTP independently ofusesModernSessionSetup().sourceVersion=280.33,controlPort, no control-only first SETUP).shkon the audio stream even whencontrolPortis still used.Tested
75UP75009LC: pairing, video, and audio.go test ./internal/airplay/