Airtame 2 & AirServer support - #32
Conversation
Added PTPInfo key handling and updated ReceiverInfo struct.
Add tests for audio security mode selection.
Add video scaling support based on MaxWidth and MaxHeight configuration.
Added a method to identify AirServer clones based on model and features.
Refactor audio security mode selection and session setup logic. Update handling of timing protocols and improve compatibility with AirServer clones.
SummaryRestores Airtame 2/AirServer Embedded compatibility that commit The implementation adapts the original behavior to the newer capability-driven scaffold instead of copying PR #32’s receiver model/name fingerprint. Changes
What
|
|
@omarroth Thank You! Two notes:
|
|
I guess I was able to answer my own question:
By the way, the use of the AAC-ELD codec was a leftover from experimentation trying to find a working audio codec. So the dependency on AAC-ELD may not be necessary at all because we are bound by video encoding latency anyhow. I will try AAC-LC next. |
|
I pushed a couple more changes to If the FDK or raw ELD stuff is still required I will look more into reviewing those changes. |
|
@omarroth I won't have access to an AirTime 2 for several weeks, but I just tried the latest AirServer for macOS which also identifies as an AirServer capability findingsInformation missing from AirServermacOS AirServer does not provide the more specific:
Doubletake normally uses that field to choose a screen-mirroring audio codec. Its absence previously caused doubletake to fall back to ALAC without recognizing that AirServer had supplied broader audio capabilities elsewhere. AirServer does provide:
Its
The Information missing from doubletakeDoubletake logged The fix added structured parsing for:
This provides a secondary capability source when the more specific The selection order should remain:
No selection should depend on Meaning of the format bitsAirServer advertises:
This means bits 2 through 25 are set. It is a broad set of supported audio format/rate/channel combinations, not a single selected codec. Known values from implementation and physical tests are:
The other set bits likely represent PCM variants and other rate/channel/profile combinations. Their exact mapping was not established from a local authoritative enum and should not be guessed. Do the bits relate to codecs?Yes, but not exclusively. Each bit encodes a complete audio-format variant, generally combining:
Therefore, Codec resultsAAC-ELDWorking configuration:
Result:
AAC-LCWorking configuration:
Result:
The delay was unchanged by:
The delay closely matches 256 AAC-LC frames: This appears to be receiver-side codec buffering. ALACWorking configuration:
The ALAC encoder itself was not the original problem. Its verbatim frame header and frame size were compared against FFmpeg:
ALAC became clean when tested under the corrected AirServer profile, particularly with the bad legacy retransmission behavior removed or replaced. The original garbage was caused by packet transport/redundancy behavior rather than malformed ALAC data. Results:
The delay again approximates 256 codec frames: Allowing for receiver startup overhead, this matches observation. Practical conclusion
AirServer model comparison
Open question: Can ALAC be made work on AirTame 2 as well?Unlikely from the current evidence. AirTame 2 rejects doubletake’s ALAC configuration before RTP decoding ( ALAC could still work with an as-yet-unknown AirTame-specific descriptor, but no available capture or receiver result supports that. AAC-ELD PatchFrom 45dea4a642b2fdcd245cf0250d885435b8a77905 Mon Sep 17 00:00:00 2001
From: 3rd3
Date: Fri, 28 Aug 2026 17:28:17 +0200
Subject: [PATCH] Fix AirServer
---
internal/airplay/audio.go | 18 +++++++++++++++++-
internal/airplay/audio_test.go | 4 ++--
internal/airplay/client.go | 21 +++++++++++++++++++++
internal/airplay/compatibility.go | 10 ++++++++++
internal/airplay/compatibility_test.go | 13 +++++++++++++
internal/airplay/mirror.go | 13 ++++++++++---
internal/airplay/receiver_server.go | 10 ++++++----
7 files changed, 79 insertions(+), 10 deletions(-)
diff --git a/internal/airplay/audio.go b/internal/airplay/audio.go
index ff7b1c6..daa9b52 100644
--- a/internal/airplay/audio.go
+++ b/internal/airplay/audio.go
@@ -57,7 +57,7 @@ func newAudioChaCha64AEAD(key []byte) (cipher.AEAD, error) {
}
func useAudioFEC(codec AudioCodec, chachaEncrypted bool) bool {
- return codec == AudioCodecALAC && !chachaEncrypted
+ return (codec == AudioCodecALAC || codec == AudioCodecAACELD) && !chachaEncrypted
}
func (c AudioCodec) isAACELD() bool {
@@ -897,6 +897,7 @@ videoReady:
}
const retransmitDepth = 8
+ const aacRedundancyDepth = 3
type audioFrame struct {
payload []byte
rtpTime uint32
@@ -942,6 +943,21 @@ videoReady:
if _, err := audioStream.sendAudioPacketWithSeqAndNonce(payload, nextRtp, frameSeq, nil); err != nil {
return fmt.Errorf("audio send: %w", err)
}
+ } else if audioStream.ct == byte(AudioCodecAACELD) {
+ // Native legacy AAC-ELD uses a rolling burst [N-2, N-1, N].
+ retransmitBuf[retransmitIdx] = audioFrame{payload: payload, rtpTime: nextRtp, seq: frameSeq}
+ retransmitIdx = (retransmitIdx + 1) % aacRedundancyDepth
+ available := frameCount
+ if available > aacRedundancyDepth {
+ available = aacRedundancyDepth
+ }
+ start := (retransmitIdx - available + aacRedundancyDepth) % aacRedundancyDepth
+ for i := 0; i < available; i++ {
+ old := retransmitBuf[(start+i)%aacRedundancyDepth]
+ if _, err := audioStream.sendAudioPacketWithSeqAndNonce(old.payload, old.rtpTime, old.seq, nil); err != nil {
+ return fmt.Errorf("AAC-ELD redundant audio send: %w", err)
+ }
+ }
} else if !burstDone {
// Initial burst phase: send frames immediately, fill retransmit buffer
nonce, err := audioStream.sendAudioPacketWithSeqAndNonce(payload, nextRtp, frameSeq, nil)
diff --git a/internal/airplay/audio_test.go b/internal/airplay/audio_test.go
index 182efda..771f839 100644
--- a/internal/airplay/audio_test.go
+++ b/internal/airplay/audio_test.go
@@ -21,8 +21,8 @@ func TestUseAudioFECDefaults(t *testing.T) {
if useAudioFEC(AudioCodecALAC, true) {
t.Fatal("expected modern encrypted sessions to disable FEC by default")
}
- if useAudioFEC(AudioCodecAACELD, false) {
- t.Fatal("AAC-ELD must not use ALAC-style redundant retransmits")
+ if !useAudioFEC(AudioCodecAACELD, false) {
+ t.Fatal("legacy AAC-ELD must use native redundant audio")
}
}
diff --git a/internal/airplay/client.go b/internal/airplay/client.go
index a524ed2..577e53c 100644
--- a/internal/airplay/client.go
+++ b/internal/airplay/client.go
@@ -48,6 +48,7 @@ type ReceiverInfo struct {
PI string `plist:"pi"`
MacAddress string `plist:"macAddress"`
Displays []DisplayInfo `plist:"displays"`
+ AudioFormats []AudioFormatInfo `plist:"audioFormats"`
hasPTPInfo bool
// rawAACELD records a protocol capability learned during pairing. Some
// AirServer-derived receivers omit supportedFormats but reveal their legacy
@@ -55,6 +56,26 @@ type ReceiverInfo struct {
rawAACELD bool
}
+// AudioFormatInfo is one entry from the legacy /info audioFormats array.
+// Some receivers omit supportedFormats but advertise their codec mask here.
+type AudioFormatInfo struct {
+ Type int `plist:"type"`
+ AudioInputFormats FormatMask `plist:"audioInputFormats"`
+ AudioOutputFormats FormatMask `plist:"audioOutputFormats"`
+}
+
+func (i *ReceiverInfo) supportsLegacyAudioOutput(mask uint64) bool {
+ if i == nil || mask == 0 {
+ return false
+ }
+ for _, format := range i.AudioFormats {
+ if uint64(format.AudioOutputFormats)&mask == mask {
+ return true
+ }
+ }
+ return false
+}
+
// FormatMask preserves the unsigned bit pattern of signed or unsigned plist
// integers. Some Apple receivers encode bufferStream with bit 63 set, which
// appears as a negative integer in the plist.
diff --git a/internal/airplay/compatibility.go b/internal/airplay/compatibility.go
index 06267da..dbdac2e 100644
--- a/internal/airplay/compatibility.go
+++ b/internal/airplay/compatibility.go
@@ -26,6 +26,7 @@ type receiverCompatibility struct {
audioConnections audioConnectionLayout
audioCodec AudioCodec
fairPlayRoots fairPlayRootPlacement
+ legacyAirServer bool
}
const (
@@ -59,6 +60,7 @@ func compatibilityForReceiver(info *ReceiverInfo, encrypted, audioEnabled bool)
audioConnections: audioLayoutControlPort,
audioCodec: audioCodec,
fairPlayRoots: fairPlayAllRoots,
+ legacyAirServer: usesLegacyAirServerMedia(info),
}
if encrypted {
// An encrypted HAP pair-verify session has a CoreUtils key holder. Stream
@@ -94,6 +96,9 @@ func screenAudioCodec(info *ReceiverInfo) (AudioCodec, error) {
if info != nil && info.SupportedFormats.ScreenStream == 0 && info.rawAACELD {
return AudioCodecAACELDRaw, nil
}
+ if usesLegacyAirServerMedia(info) {
+ return AudioCodecAACELD, nil
+ }
if info == nil || info.SupportedFormats.ScreenStream == 0 {
return AudioCodecALAC, nil
}
@@ -109,6 +114,11 @@ func screenAudioCodec(info *ReceiverInfo) (AudioCodec, error) {
)
}
+func usesLegacyAirServerMedia(info *ReceiverInfo) bool {
+ return info != nil && !info.rawAACELD && info.SupportedFormats.ScreenStream == 0 &&
+ info.supportsLegacyAudioOutput(screenAudioFormatAACELD44100Stereo)
+}
+
func supportsPTPSourceVersion(sourceVersion string) bool {
major, minor, patch, ok := parseSourceVersion(sourceVersion)
if !ok || major == 377 && minor == 40 {
diff --git a/internal/airplay/compatibility_test.go b/internal/airplay/compatibility_test.go
index 469f35a..9c1ed8e 100644
--- a/internal/airplay/compatibility_test.go
+++ b/internal/airplay/compatibility_test.go
@@ -195,6 +195,19 @@ func TestNegotiatedAirServerProfileSelectsRawAACELDWhenFormatsAreOmitted(t *test
}
}
+func TestLegacyAudioFormatsSelectStandardAACELDWhenScreenFormatsAreOmitted(t *testing.T) {
+ info := &ReceiverInfo{AudioFormats: []AudioFormatInfo{{
+ Type: 100, AudioOutputFormats: FormatMask(0x3fffffc),
+ }}}
+ policy, err := compatibilityForReceiver(info, false, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if policy.audioCodec != AudioCodecAACELD || !policy.legacyAirServer {
+ t.Fatalf("policy = codec %d legacy=%t, want AAC-ELD legacy profile", policy.audioCodec, policy.legacyAirServer)
+ }
+}
+
func TestUnsupportedScreenAudioFormatDoesNotBlockVideoOnlySession(t *testing.T) {
info := &ReceiverInfo{SupportedFormats: StreamFormats{ScreenStream: 0x800000}}
policy, err := compatibilityForReceiver(info, false, false)
diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go
index 4ba05f6..6c98784 100644
--- a/internal/airplay/mirror.go
+++ b/internal/airplay/mirror.go
@@ -464,6 +464,10 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
audioStreamConnectionID := int64(time.Now().UnixNano() & 0x7FFFFFFFFFFFFFFF)
selectedAudioCodec := policy.audioCodec
+ if policy.legacyAirServer {
+ // Match the latency explicitly selected by the captured native sender.
+ sessionLatency = time.Duration(7497) * time.Second / 44100
+ }
// Real Apple senders use streamConnectionID as the RTSP URI path.
// Control, audio, RECORD, and SET_PARAMETER share the audio URI; video uses
// a separate URI with its own streamConnectionID.
@@ -492,6 +496,9 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
var attemptedEventPort int
audioControlLPort := audioCtrlConn.LocalAddr().(*net.UDPAddr).Port
audioLatencySamples := samplesFor44k1(sessionLatency)
+ if policy.legacyAirServer {
+ audioLatencySamples = 7497
+ }
skipRecord := false
firstSetup := true
@@ -741,7 +748,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
} else {
request = setupRequest.legacyStreamPlist(stream)
}
- if policy.fairPlayOnStreams() {
+ if policy.fairPlayOnStreams() && !policy.legacyAirServer {
if !addFairPlayRootFields(request, c.FpEkey, c.fpIV, true) && audioMode == audioSecurityLegacyAES {
dbg("[SETUP] WARNING: no FairPlay ekey/eiv — audio will likely not work")
}
@@ -847,7 +854,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
}
// Encryption keys: shk/shiv go inside the video stream descriptor
- if encKey != nil {
+ if encKey != nil && !policy.legacyAirServer {
videoStreamDesc["shk"] = encKey
videoStreamDesc["shiv"] = encIV
}
@@ -860,7 +867,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
}
// Legacy FairPlay sessions derive video keys from material at the SETUP root,
// independent of whether the plist is stream-only.
- if policy.fairPlayOnStreams() && encKey != nil {
+ if policy.fairPlayOnStreams() && encKey != nil && !policy.legacyAirServer {
addFairPlayRootFields(videoSetupPlist, c.FpEkey, encIV, false)
}
dbg("[SETUP] phase %d (video): streamConnectionID=%d", videoPhase, videoStreamConnectionID)
diff --git a/internal/airplay/receiver_server.go b/internal/airplay/receiver_server.go
index 9f0ab93..273ae0d 100644
--- a/internal/airplay/receiver_server.go
+++ b/internal/airplay/receiver_server.go
@@ -1091,10 +1091,12 @@ func (c *receiverConnection) validateSetup(setup map[string]any, streams []map[s
}
if profile.fairPlayRootKeys && c.fairplay != nil && c.fairplay.complete() {
- if len(plistBytes(setup["ekey"])) == 0 || len(plistBytes(setup["eiv"])) == 0 {
+ controlOnlyFairPlay := profile.audioCodec == AudioCodecAACELD && c.hap == nil &&
+ (kind == receiverSetupAudio || kind == receiverSetupVideo)
+ if !controlOnlyFairPlay && (len(plistBytes(setup["ekey"])) == 0 || len(plistBytes(setup["eiv"])) == 0) {
return fmt.Errorf("SETUP omitted root FairPlay ekey/eiv")
}
- if kind != receiverSetupVideo && plistInt(setup["et"]) != 32 {
+ if !controlOnlyFairPlay && kind != receiverSetupVideo && plistInt(setup["et"]) != 32 {
return fmt.Errorf("SETUP omitted FairPlay et=32")
}
}
@@ -1134,9 +1136,9 @@ func (c *receiverConnection) validateSetup(setup map[string]any, streams []map[s
if profile.audioSHKWithHAP && c.hap != nil && len(plistBytes(stream["shk"])) != chacha20poly1305.KeySize {
return fmt.Errorf("encrypted audio descriptor omitted 32-byte shk")
}
- if profile.audioCodec == AudioCodecAACELD {
+ if profile.audioCodec == AudioCodecAACELD && c.hap != nil {
if _, ok := stream["redundantAudio"]; ok {
- return fmt.Errorf("AAC-ELD descriptor must not enable redundantAudio")
+ return fmt.Errorf("encrypted AAC-ELD descriptor must not enable redundantAudio")
}
}
return nil
--
2.55.0Changes for probing audio codecs compatible with AirServer: diff --git a/internal/airplay/audio.go b/internal/airplay/audio.go
index daa9b52..abcf133 100644
--- a/internal/airplay/audio.go
+++ b/internal/airplay/audio.go
@@ -943,8 +943,8 @@ videoReady:
if _, err := audioStream.sendAudioPacketWithSeqAndNonce(payload, nextRtp, frameSeq, nil); err != nil {
return fmt.Errorf("audio send: %w", err)
}
- } else if audioStream.ct == byte(AudioCodecAACELD) {
- // Native legacy AAC-ELD uses a rolling burst [N-2, N-1, N].
+ } else if audioStream.ct == byte(AudioCodecALAC) || audioStream.ct == byte(AudioCodecAACELD) {
+ // Native legacy screen audio uses a rolling burst [N-2, N-1, N].
retransmitBuf[retransmitIdx] = audioFrame{payload: payload, rtpTime: nextRtp, seq: frameSeq}
retransmitIdx = (retransmitIdx + 1) % aacRedundancyDepth
available := frameCount
diff --git a/internal/airplay/compatibility.go b/internal/airplay/compatibility.go
index dbdac2e..04148e6 100644
--- a/internal/airplay/compatibility.go
+++ b/internal/airplay/compatibility.go
@@ -97,7 +97,7 @@ func screenAudioCodec(info *ReceiverInfo) (AudioCodec, error) {
return AudioCodecAACELDRaw, nil
}
if usesLegacyAirServerMedia(info) {
- return AudioCodecAACELD, nil
+ return AudioCodecALAC, nil
}
if info == nil || info.SupportedFormats.ScreenStream == 0 {
return AudioCodecALAC, nil
diff --git a/internal/airplay/compatibility_test.go b/internal/airplay/compatibility_test.go
index 9c1ed8e..2b6dc66 100644
--- a/internal/airplay/compatibility_test.go
+++ b/internal/airplay/compatibility_test.go
@@ -203,8 +203,8 @@ func TestLegacyAudioFormatsSelectStandardAACELDWhenScreenFormatsAreOmitted(t *te
if err != nil {
t.Fatal(err)
}
- if policy.audioCodec != AudioCodecAACELD || !policy.legacyAirServer {
- t.Fatalf("policy = codec %d legacy=%t, want AAC-ELD legacy profile", policy.audioCodec, policy.legacyAirServer)
+ if policy.audioCodec != AudioCodecALAC || !policy.legacyAirServer {
+ t.Fatalf("policy = codec %d legacy=%t, want ALAC legacy profile", policy.audioCodec, policy.legacyAirServer)
}
}
|
Summary
Adds minimal compatibility support for Airtame 2/AirServer-style receivers advertising as
AppleTV5,3.SETUPordering.SETUPrequests./infodoes not advertise a display size and scales X11 capture accordingly.timingPortand responding to subsequent timing requests.CLOCK_BOOTTIMEfor the boot-relative timestamps expected by these receivers, with a portable fallback.ct=7audioFormat=0x1000000Open question: Device detection
Currently, the Airtame 2 is detected with the following function:
However, I doubt official AirPlay clients make a distinction like this. More likely, they treat all AppleTV5,3 the same. However, because I lack access to this particular Apple TV model I decided to leave in this function.
Not tested: AirServer
So far, I have not tested AirServer software as a playback target itself. It is known Airtame 2 uses the AirServer for Embedded Linux, but it is not available for download. One may be able to test it with the desktop versions.
Feel free to pull this PR entirely or just use as an inspiration.
Note: The four commits from Aug 13, 2026 are actually unrelated to this PR. I think I messed something up in this branch such that the other commits undo these.