Skip to content

Commit 309ce0c

Browse files
committed
Fix time-sync and support for non-AppleTV
1 parent 1802b2b commit 309ce0c

9 files changed

Lines changed: 196 additions & 23 deletions

File tree

cmd/doubletake/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"errors"
56
"flag"
67
"fmt"
78
"log"
@@ -234,7 +235,10 @@ func main() {
234235
// for Apple TV compatibility in the normal modern flow.
235236
if client.FpEkey == nil {
236237
if err := client.FairPlaySetup(ctx); err != nil {
237-
log.Fatalf("FairPlay setup failed: %v", err)
238+
if !errors.Is(err, airplay.ErrFairPlayUnsupported) {
239+
log.Fatalf("FairPlay setup failed: %v", err)
240+
}
241+
log.Printf("FairPlay SAP unsupported (%v); continuing with pair-verify DataStream setup", err)
238242
} else {
239243
log.Println("FairPlay setup complete")
240244
}

internal/airplay/audio.go

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,47 @@ func (ac *AudioCapture) ReadFrame(buf []byte) (int, error) {
176176
return n, nil
177177
}
178178

179+
// DrainStale discards any PCM that buffered in the OS pipe between capture
180+
// start and the first read. The capture pipeline starts producing audio
181+
// immediately, but streaming does not begin until the first video frame is
182+
// sent; during that gap the kernel pipe accumulates a FIFO backlog that would
183+
// otherwise be read in order forever, leaving every frame permanently stale and
184+
// audio lagging video. Draining once just before the read loop starts streaming
185+
// from the freshest sample. It removes whatever backlog actually accumulated —
186+
// no fixed latency value is assumed.
187+
func (ac *AudioCapture) DrainStale() {
188+
type deadlineReader interface {
189+
SetReadDeadline(t time.Time) error
190+
}
191+
dr, ok := ac.pcmPipe.(deadlineReader)
192+
if !ok {
193+
return
194+
}
195+
buf := make([]byte, 32*1024)
196+
var discarded int
197+
for {
198+
// Re-arm a short idle timeout each read: while a backlog exists, reads
199+
// return buffered data immediately; once the pipe is empty the read
200+
// blocks and this deadline fires before the next live frame (~8ms)
201+
// arrives, ending the drain. This is a poll timeout, not a latency.
202+
if err := dr.SetReadDeadline(time.Now().Add(2 * time.Millisecond)); err != nil {
203+
break
204+
}
205+
n, err := ac.pcmPipe.Read(buf)
206+
discarded += n
207+
if err != nil {
208+
break
209+
}
210+
}
211+
// Restore blocking reads for steady-state streaming.
212+
_ = dr.SetReadDeadline(time.Time{})
213+
if discarded > 0 {
214+
const bytesPerSecond = 44100 * 2 * 2 // 44.1kHz, stereo, S16LE
215+
dbg("[AUDIO] drained %d bytes (~%.0fms) of startup backlog before streaming",
216+
discarded, float64(discarded)/bytesPerSecond*1000)
217+
}
218+
}
219+
179220
func (ac *AudioCapture) Stop() {
180221
if ac.stopped {
181222
return
@@ -413,7 +454,7 @@ func (s *MirrorSession) setupAudioStream(dataPort, controlPort int, aesKey, aesI
413454
as.chachaNonceMode.String(), as.chachaAADMode.String())
414455
}
415456
if latencyOverride > 0 {
416-
dbg("[AUDIO] receiver audio latency override: %d samples", latencySamples)
457+
dbg("[AUDIO] audio latency: %d samples", latencySamples)
417458
}
418459
dbg("[AUDIO] local ports: data=%d (→remote %d) ctrl=%d (→remote %d)",
419460
dataLocalPort, dataPort, ctrlLocalPort, controlPort)
@@ -594,12 +635,18 @@ func (as *AudioStream) sendSyncPacket(ntpTime uint64, isFirst bool) error {
594635
latencySamples := as.latencySamples
595636
as.mu.Unlock()
596637

638+
// anchorLatency is the playout lead time reported to the receiver: the newest
639+
// audio we have sent (rtpNow) plays anchorLatency/44100 seconds after "now".
640+
// This equals the negotiated session latency, the same forward bias video
641+
// frames carry, so audio and video captured at the same instant play together.
642+
anchorLatency := latencySamples
643+
597644
// Sync packet: 20 bytes total (8-byte RTP-like header + 12-byte payload)
598645
// Format observed from real Apple senders:
599646
// header: V=2, X=1(first)/0(subsequent), M=1, PT=84, seq=4 (constant)
600647
// RTP timestamp = current playback position (sync_rtp)
601648
// payload: NTP_hi(4) + NTP_lo(4) + next_rtp(4)
602-
// next_rtp = sync_rtp + latencySamples
649+
// next_rtp = current receive head (rtpNow)
603650
packet := make([]byte, 20)
604651
if isFirst {
605652
packet[0] = 0x90 // V=2, X=1
@@ -609,16 +656,16 @@ func (as *AudioStream) sendSyncPacket(ntpTime uint64, isFirst bool) error {
609656
packet[1] = 0xd4 // M=1, PT=84
610657
// seq field is constant 4 in working pcap captures
611658
binary.BigEndian.PutUint16(packet[2:4], 4)
612-
// Bytes 4-7: sync_rtp = current playback position
659+
// Bytes 4-7: sync_rtp = current playback position = receive head - anchorLatency
613660
syncRtp := rtpNow
614-
if rtpNow >= latencySamples {
615-
syncRtp = rtpNow - latencySamples
661+
if rtpNow >= anchorLatency {
662+
syncRtp = rtpNow - anchorLatency
616663
}
617664
binary.BigEndian.PutUint32(packet[4:8], syncRtp)
618665
// Bytes 8-15: NTP timestamp (current wall-clock time)
619666
binary.BigEndian.PutUint64(packet[8:16], ntpTime)
620-
// Bytes 16-19: next_rtp = sync_rtp + latencySamples
621-
binary.BigEndian.PutUint32(packet[16:20], syncRtp+latencySamples)
667+
// Bytes 16-19: next_rtp = current receive head
668+
binary.BigEndian.PutUint32(packet[16:20], rtpNow)
622669

623670
_, err := as.ctrlConn.WriteTo(packet, as.ctrlAddr)
624671
return err
@@ -744,6 +791,11 @@ func (s *MirrorSession) StreamAudio(ctx context.Context, capture *AudioCapture,
744791
burstDone := false
745792
frameBuf := make([]byte, 8192)
746793

794+
// Capture started before video did, so the OS pipe holds a backlog of stale
795+
// audio accumulated while we waited for the first video frame. Drop it so we
796+
// begin streaming from the freshest sample and audio lines up with video.
797+
capture.DrainStale()
798+
747799
for {
748800
select {
749801
case <-ctx.Done():

internal/airplay/client.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
type ReceiverInfo struct {
2525
Name string `plist:"name"`
2626
Model string `plist:"model"`
27+
Manufacturer string `plist:"manufacturer"`
2728
DeviceID string `plist:"deviceID"`
2829
ProtocolVersion string `plist:"protocolVersion"`
2930
SourceVersion string `plist:"sourceVersion"`
@@ -40,6 +41,16 @@ type ReceiverInfo struct {
4041
MacAddress string `plist:"macAddress"`
4142
}
4243

44+
// HTTPStatusError is returned when a receiver responds with a non-2xx RTSP/HTTP status.
45+
type HTTPStatusError struct {
46+
StatusCode int
47+
Body []byte
48+
}
49+
50+
func (e *HTTPStatusError) Error() string {
51+
return fmt.Sprintf("HTTP %d (body: %s)", e.StatusCode, string(e.Body))
52+
}
53+
4354
// AirPlayClient manages the connection to an AirPlay receiver.
4455
type AirPlayClient struct {
4556
host string
@@ -322,7 +333,7 @@ func (c *AirPlayClient) readPlaintextHTTPResponse() ([]byte, map[string]string,
322333
io.ReadFull(c.conn, errBody)
323334
}
324335
dbg("[READ] error response body (%d bytes): %s", len(errBody), hex.EncodeToString(errBody))
325-
return nil, headers, fmt.Errorf("HTTP %d (body: %s)", statusCode, string(errBody))
336+
return nil, headers, &HTTPStatusError{StatusCode: statusCode, Body: errBody}
326337
}
327338

328339
if contentLength == 0 {
@@ -390,7 +401,7 @@ func (c *AirPlayClient) readEncryptedHTTPResponse() ([]byte, map[string]string,
390401
remaining = remaining[:contentLength]
391402
}
392403
dbg("[ENC-READ] error response body (%d bytes): %s", len(remaining), hex.EncodeToString(remaining))
393-
return nil, headers, fmt.Errorf("HTTP %d (body: %s)", statusCode, string(remaining))
404+
return nil, headers, &HTTPStatusError{StatusCode: statusCode, Body: remaining}
394405
}
395406

396407
if contentLength == 0 {

internal/airplay/discovery.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"strconv"
77
"strings"
8+
"time"
89

910
"github.com/grandcat/zeroconf"
1011
)
@@ -152,3 +153,23 @@ func (d *AirPlayDevice) SupportsScreen() bool {
152153
func (d *AirPlayDevice) SupportsTransientPairing() bool {
153154
return d.Features&FeatureTransientPairing != 0
154155
}
156+
157+
func (d *AirPlayDevice) SupportsFairPlaySAP() bool {
158+
return d.Features&FeatureFPSAP25 != 0
159+
}
160+
161+
func (i *ReceiverInfo) SupportsFairPlaySAP() bool {
162+
return i != nil && i.Features&FeatureFPSAP25 != 0
163+
}
164+
165+
// playoutLatencyFloor returns the minimum playout lead this receiver needs.
166+
// Modern Apple receivers advertise FairPlay SAP and have robust audio jitter
167+
// buffers, so they can play at very low latency (floor 0). Receivers without it
168+
// (Roku and other third-party AirPlay implementations) need a conservative lead
169+
// or they drop audio they can no longer schedule.
170+
func (i *ReceiverInfo) playoutLatencyFloor() time.Duration {
171+
if i != nil && i.SupportsFairPlaySAP() {
172+
return 0
173+
}
174+
return conservativePlayoutLatency
175+
}

internal/airplay/discovery_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,21 @@ func TestUnescapeDNSName(t *testing.T) {
3333
})
3434
}
3535
}
36+
37+
func TestSupportsFairPlaySAP(t *testing.T) {
38+
rokuFeatures := uint64(0x38bcf46007f8ad0)
39+
if (&ReceiverInfo{Features: rokuFeatures}).SupportsFairPlaySAP() {
40+
t.Fatalf("Roku feature mask unexpectedly advertises FPSAP")
41+
}
42+
if (&AirPlayDevice{Features: rokuFeatures}).SupportsFairPlaySAP() {
43+
t.Fatalf("Roku discovery feature mask unexpectedly advertises FPSAP")
44+
}
45+
46+
withFairPlay := rokuFeatures | FeatureFPSAP25
47+
if !(&ReceiverInfo{Features: withFairPlay}).SupportsFairPlaySAP() {
48+
t.Fatalf("ReceiverInfo with FPSAP bit did not advertise FairPlay SAP")
49+
}
50+
if !(&AirPlayDevice{Features: withFairPlay}).SupportsFairPlaySAP() {
51+
t.Fatalf("AirPlayDevice with FPSAP bit did not advertise FairPlay SAP")
52+
}
53+
}

internal/airplay/fairplay.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto/rand"
66
"crypto/sha512"
77
"encoding/hex"
8+
"errors"
89
"fmt"
910

1011
"doubletake/internal/fpemu"
@@ -13,6 +14,8 @@ import (
1314
// fairPlayM1 is the fixed m1 blob that matches the snapshot state.
1415
var fairPlayM1 = mustDecodeHexFP("46504c590301010000000004020003bb")
1516

17+
var ErrFairPlayUnsupported = errors.New("receiver does not support FairPlay SAP")
18+
1619
func mustDecodeHexFP(s string) []byte {
1720
b, err := hex.DecodeString(s)
1821
if err != nil {
@@ -24,6 +27,10 @@ func mustDecodeHexFP(s string) []byte {
2427
// FairPlaySetup performs the complete FairPlay SAP handshake using the
2528
// standalone ARM64 interpreter.
2629
func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
30+
if c.info != nil && !c.info.SupportsFairPlaySAP() {
31+
return fmt.Errorf("%w: FPSAP feature bit is not advertised (features=0x%x)", ErrFairPlayUnsupported, c.info.Features)
32+
}
33+
2734
dbg("[FP] starting FairPlay SAP handshake...")
2835

2936
// Phase 1: Send m1, receive m2
@@ -34,6 +41,10 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
3441
m2, err := c.httpRequest("POST", "/fp-setup", "application/octet-stream", m1,
3542
map[string]string{"X-Apple-ET": "32"})
3643
if err != nil {
44+
var statusErr *HTTPStatusError
45+
if errors.As(err, &statusErr) && statusErr.StatusCode == 404 {
46+
return fmt.Errorf("%w: /fp-setup returned 404", ErrFairPlayUnsupported)
47+
}
3748
return fmt.Errorf("fp-setup phase 1 (m1): %w", err)
3849
}
3950

@@ -61,6 +72,10 @@ func (c *AirPlayClient) FairPlaySetup(ctx context.Context) error {
6172
m4, err := c.httpRequest("POST", "/fp-setup", "application/octet-stream", m3,
6273
map[string]string{"X-Apple-ET": "32"})
6374
if err != nil {
75+
var statusErr *HTTPStatusError
76+
if errors.As(err, &statusErr) && statusErr.StatusCode == 404 {
77+
return fmt.Errorf("%w: /fp-setup returned 404 during phase 2", ErrFairPlayUnsupported)
78+
}
6479
return fmt.Errorf("fp-setup phase 2 (m3): %w", err)
6580
}
6681

internal/airplay/latency.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,19 @@ import (
66
"time"
77
)
88

9-
const defaultTargetLatency = 100 * time.Millisecond
9+
const defaultTargetLatency = 1 * time.Millisecond
10+
11+
// conservativePlayoutLatency is the playout lead required by receivers that lack
12+
// a robust audio jitter buffer (third-party AirPlay implementations such as
13+
// Roku, which do not advertise FairPlay SAP). The control-port sync anchor
14+
// reports that the newest audio frame plays this far in the future, which is
15+
// also the buffer lead the receiver has to schedule each packet before its play
16+
// time. With too little lead these receivers drop audio they can no longer
17+
// schedule. Modern Apple receivers buffer aggressively and do not need this, so
18+
// it is applied per-receiver (see ReceiverInfo.playoutLatencyFloor), not
19+
// globally — audio and video share whatever latency is chosen so they stay in
20+
// sync.
21+
const conservativePlayoutLatency = 500 * time.Millisecond
1022

1123
var targetLatencyNS atomic.Int64
1224

@@ -36,7 +48,10 @@ func TargetLatency() time.Duration {
3648
}
3749

3850
func targetLatencySamples44k1() uint32 {
39-
d := TargetLatency()
51+
return samplesFor44k1(TargetLatency())
52+
}
53+
54+
func samplesFor44k1(d time.Duration) uint32 {
4055
samples := int64(math.Round(float64(d) * 44100.0 / float64(time.Second)))
4156
if samples < 1 {
4257
samples = 1

0 commit comments

Comments
 (0)