Skip to content

Airtame 2 & AirServer support - #32

Draft
3rd3 wants to merge 14 commits into
omarroth:mainfrom
3rd3:airtame2
Draft

Airtame 2 & AirServer support#32
3rd3 wants to merge 14 commits into
omarroth:mainfrom
3rd3:airtame2

Conversation

@3rd3

@3rd3 3rd3 commented Aug 15, 2026

Copy link
Copy Markdown

Summary

Adds minimal compatibility support for Airtame 2/AirServer-style receivers advertising as AppleTV5,3.

  • Uses session-first control/audio/video SETUP ordering.
  • Includes the required FairPlay key material in the relevant SETUP requests.
  • Falls back to a 1920×1080 framebuffer when /info does not advertise a display size and scales X11 capture accordingly.
  • Completes the bidirectional legacy NTP timing handshake by probing the receiver-returned timingPort and responding to subsequent timing requests.
  • Uses Linux CLOCK_BOOTTIME for the boot-relative timestamps expected by these receivers, with a portable fallback.
  • Selects raw AAC-ELD audio for affected receivers:
    • ct=7
    • audioFormat=0x1000000
    • 44.1 kHz stereo
    • 480 samples per frame
    • no redundant audio/FEC
  • Applies the negotiated audio codec to both direct and daemon streaming paths.

Open question: Device detection

Currently, the Airtame 2 is detected with the following function:

// looksLikeAirServerClone reports receivers that impersonate a first-party
// Apple TV in /info (model + CoreUtils feature bits) but omit a display list
// and do not speak CoreUtils pair-setup. AirTame 2 / AirServer answer as
// AppleTV5,3 with AirPlay/375.3 and reject modern HKP type 5 M1 with HTTP 500.
func (i *ReceiverInfo) looksLikeAirServerClone() bool {
	return i != nil &&
		i.Model == "AppleTV5,3" &&
		len(i.Displays) == 0 &&
		i.Features&featureThirdPartyReceiverMask == 0
}

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.

@omarroth

Copy link
Copy Markdown
Owner

Thanks for this! I'm trying to pull a couple similar changes together for improving compatibility with older AirPlay receivers. Would you be willing to test the dev branch to see if it correctly implements/replaces #31 and #32?

@3rd3

3rd3 commented Aug 22, 2026

Copy link
Copy Markdown
Author

Summary

Restores Airtame 2/AirServer Embedded compatibility that commit cf95085 did not fully carry over from PR #32.

The implementation adapts the original behavior to the newer capability-driven scaffold instead of copying PR #32’s receiver model/name fingerprint.

Changes

  • Treat transient HAP pair-setup failure at M4 with authentication error 2 as a protocol-negotiation failure.
  • Attempt raw AirPlay pairing only after that specific failure.
  • Continue only if raw pair-setup and pair-verify complete successfully.
  • Record the resulting legacy AirServer media capability on the receiver session.
  • Select raw AAC-ELD when that negotiated profile omits supportedFormats.screenStream.
  • Add the AirServer Embedded AAC-ELD variant:
    • ct=7
    • audioFormat=0x1000000
    • 44.1 kHz stereo
    • 480 samples per frame
    • no redundant audio/FEC
  • Reuse the existing optional FDK-AAC encoder for both ct=7 raw AAC-ELD and standard ct=8 AAC-ELD.
  • Add pairing-negotiation, codec-selection, codec-parameter, and FEC regression tests.

What cf95085 failed to incorporate from PR #32

Commit cf95085 included much of the newer generalized receiver scaffold, including:

  • AAC-ELD encoder support
  • capability-based audio selection
  • session-first SETUP
  • FairPlay root placement
  • NTP handling
  • receiver profiles and compatibility tests

However, it missed two behaviors essential for the actual Airtame 2 receiver:

  1. Pairing fallback after HAP M4 rejection

    The receiver accepts transient HAP M1 and returns an SRP challenge, but rejects transient SRP at M4 with authentication error 2. cf95085 treated this as a credentials failure, prompted for a PIN, and then failed with HTTP 500. It therefore never reached the receiver’s working raw pairing protocol.

  2. Raw AAC-ELD selection when audio capabilities are omitted

    PR Airtame 2 & AirServer support #32 explicitly selected raw AAC-ELD with compression type 7 for this receiver. The newer scaffold only selected audio from supportedFormats.screenStream; Airtame omits that field, so cf95085 defaulted to ALAC (ct=2). Although AAC-ELD support existed, it was never selected. Its existing AAC-ELD representation also used standard ct=8, not PR Airtame 2 & AirServer support #32’s required raw ct=7 variant.

This PR derives both decisions from observed protocol behavior rather than matching AppleTV5,3, receiver names, or exact feature masks.

Validation

  • CGO_ENABLED=1 go test -tags fdk_aac ./internal/airplay ./cmd/doubletake
  • Built using libfdk-aac 2.0.3.
  • Tested against Airtame receiver 192.168.178.71.
  • Raw pairing, FairPlay, video, and audio all completed successfully.
  • Video and sound were confirmed working on the physical receiver.

Patch

From a9821f6e36e6b8c2fc37a1b362cb93101a3d7dff Mon Sep 17 00:00:00 2001
From: 3rd3
Date: Sat, 22 Aug 2026 00:51:58 +0200
Subject: [PATCH] Fix AirServer

---
 internal/airplay/audio.go                    | 19 ++--
 internal/airplay/audio_test.go               | 10 ++
 internal/airplay/client.go                   |  4 +
 internal/airplay/compatibility.go            |  3 +
 internal/airplay/compatibility_test.go       | 11 +++
 internal/airplay/pairing.go                  | 31 ++++++-
 internal/airplay/pairing_negotiation_test.go | 96 ++++++++++++++++++++
 7 files changed, 165 insertions(+), 9 deletions(-)

diff --git a/internal/airplay/audio.go b/internal/airplay/audio.go
index 2033568..ff7b1c6 100644
--- a/internal/airplay/audio.go
+++ b/internal/airplay/audio.go
@@ -26,8 +26,9 @@ type audioChaChaNonceMode int
 type audioChaChaAADMode int
 
 const (
-	AudioCodecALAC   AudioCodec = 2 // ct=2, spf=352, audioFormat=0x40000
-	AudioCodecAACELD AudioCodec = 8 // ct=8, spf=480, audioFormat=0x1000000
+	AudioCodecALAC      AudioCodec = 2 // ct=2, spf=352, audioFormat=0x40000
+	AudioCodecAACELDRaw AudioCodec = 7 // ct=7, raw AAC-ELD used by AirServer Embedded
+	AudioCodecAACELD    AudioCodec = 8 // ct=8, spf=480, audioFormat=0x1000000
 
 	audioSecurityLegacyAES audioSecurityMode = iota
 	audioSecurityChaCha
@@ -59,6 +60,10 @@ func useAudioFEC(codec AudioCodec, chachaEncrypted bool) bool {
 	return codec == AudioCodecALAC && !chachaEncrypted
 }
 
+func (c AudioCodec) isAACELD() bool {
+	return c == AudioCodecAACELDRaw || c == AudioCodecAACELD
+}
+
 func defaultAudioChaChaNonceMode() audioChaChaNonceMode {
 	return audioChaChaNonceCounter
 }
@@ -70,8 +75,8 @@ func defaultAudioChaChaAADMode() audioChaChaAADMode {
 // Info returns SETUP parameters for the supported mirrored-audio codec.
 func (c AudioCodec) Info() (ct int64, spf int64, audioFormat int64, latencyMin int64, latencyMax int64, latencySamples uint32) {
 	latency := targetLatencySamples44k1()
-	if c == AudioCodecAACELD {
-		return int64(AudioCodecAACELD), 480, 0x1000000, 0, int64(latency), latency
+	if c.isAACELD() {
+		return int64(c), 480, 0x1000000, 0, int64(latency), latency
 	}
 	return 2, 352, 0x40000, 0, int64(latency), latency
 }
@@ -110,7 +115,7 @@ type AudioCapture struct {
 // AAC-ELD is available in builds made with -tags fdk_aac and libfdk-aac.
 func StartAudioCapture(ctx context.Context, testTone bool, codec AudioCodec) (*AudioCapture, error) {
 	captureCtx, cancel := context.WithCancel(ctx)
-	if codec != AudioCodecALAC && codec != AudioCodecAACELD {
+	if codec != AudioCodecALAC && !codec.isAACELD() {
 		cancel()
 		return nil, fmt.Errorf("unsupported audio codec %d", codec)
 	}
@@ -143,7 +148,7 @@ func StartAudioCapture(ctx context.Context, testTone bool, codec AudioCodec) (*A
 		waitCh: make(chan struct{}),
 		codec:  codec,
 	}
-	if codec == AudioCodecAACELD {
+	if codec.isAACELD() {
 		var err error
 		ac.eld, err = newELDEncoder()
 		if err != nil {
@@ -216,7 +221,7 @@ func (ac *AudioCapture) ReadFrame(buf []byte) (int, error) {
 	if _, err := io.ReadFull(ac.pcmPipe, pcm); err != nil {
 		return 0, err
 	}
-	if ac.codec == AudioCodecAACELD {
+	if ac.codec.isAACELD() {
 		ac.eldMu.Lock()
 		defer ac.eldMu.Unlock()
 		if ac.eld == nil {
diff --git a/internal/airplay/audio_test.go b/internal/airplay/audio_test.go
index ec0575f..182efda 100644
--- a/internal/airplay/audio_test.go
+++ b/internal/airplay/audio_test.go
@@ -46,6 +46,16 @@ func TestAACELDCodecInfoMatchesAirPlayCompressionType(t *testing.T) {
 	}
 }
 
+func TestRawAACELDCodecInfoMatchesAirServerCompressionType(t *testing.T) {
+	ct, spf, format, _, _, _ := AudioCodecAACELDRaw.Info()
+	if ct != 7 || spf != 480 || format != 0x1000000 {
+		t.Fatalf("raw AAC-ELD info = ct=%d spf=%d format=0x%x, want ct=7 spf=480 format=0x1000000", ct, spf, format)
+	}
+	if useAudioFEC(AudioCodecAACELDRaw, false) {
+		t.Fatal("raw AAC-ELD must not use redundant audio/FEC")
+	}
+}
+
 func TestAudioLatencySamplesForCodec(t *testing.T) {
 	defaultLatency := targetLatencySamples44k1()
 	tests := []struct {
diff --git a/internal/airplay/client.go b/internal/airplay/client.go
index 4014bed..a524ed2 100644
--- a/internal/airplay/client.go
+++ b/internal/airplay/client.go
@@ -49,6 +49,10 @@ type ReceiverInfo struct {
 	MacAddress                    string              `plist:"macAddress"`
 	Displays                      []DisplayInfo       `plist:"displays"`
 	hasPTPInfo                    bool
+	// rawAACELD records a protocol capability learned during pairing. Some
+	// AirServer-derived receivers omit supportedFormats but reveal their legacy
+	// media profile by rejecting transient HAP at M4 and completing raw pairing.
+	rawAACELD bool
 }
 
 // FormatMask preserves the unsigned bit pattern of signed or unsigned plist
diff --git a/internal/airplay/compatibility.go b/internal/airplay/compatibility.go
index bb7780c..06267da 100644
--- a/internal/airplay/compatibility.go
+++ b/internal/airplay/compatibility.go
@@ -91,6 +91,9 @@ func compatibilityForReceiver(info *ReceiverInfo, encrypted, audioEnabled bool)
 }
 
 func screenAudioCodec(info *ReceiverInfo) (AudioCodec, error) {
+	if info != nil && info.SupportedFormats.ScreenStream == 0 && info.rawAACELD {
+		return AudioCodecAACELDRaw, 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 8ac32de..469f35a 100644
--- a/internal/airplay/compatibility_test.go
+++ b/internal/airplay/compatibility_test.go
@@ -184,6 +184,17 @@ func TestScreenAudioCodecUsesAdvertisedFormatMask(t *testing.T) {
 	}
 }
 
+func TestNegotiatedAirServerProfileSelectsRawAACELDWhenFormatsAreOmitted(t *testing.T) {
+	info := &ReceiverInfo{rawAACELD: true}
+	policy, err := compatibilityForReceiver(info, false, true)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if policy.audioCodec != AudioCodecAACELDRaw {
+		t.Fatalf("audio codec = %d, want raw AAC-ELD %d", policy.audioCodec, AudioCodecAACELDRaw)
+	}
+}
+
 func TestUnsupportedScreenAudioFormatDoesNotBlockVideoOnlySession(t *testing.T) {
 	info := &ReceiverInfo{SupportedFormats: StreamFormats{ScreenStream: 0x800000}}
 	policy, err := compatibilityForReceiver(info, false, false)
diff --git a/internal/airplay/pairing.go b/internal/airplay/pairing.go
index 8384997..74bcd11 100644
--- a/internal/airplay/pairing.go
+++ b/internal/airplay/pairing.go
@@ -248,6 +248,14 @@ func (c *AirPlayClient) performTransientSetupAndVerify(ctx context.Context) erro
 			if rawErr := c.performRawSetupAndVerify(ctx); rawErr != nil {
 				return fmt.Errorf("pair-setup: advertised HAP protocol was rejected (%v); raw fallback: %w", err, rawErr)
 			}
+			var setupErr *pairSetupProtocolError
+			if errors.As(err, &setupErr) && setupErr.state == 4 && setupErr.code == 2 {
+				// This is the capability signal observed from Airtame 2's embedded
+				// AirServer implementation. It omits supportedFormats, but expects
+				// raw AAC-ELD (compression type 7) rather than the ALAC default.
+				c.info.rawAACELD = true
+				dbg("[PAIR] negotiated legacy AirServer media profile (raw AAC-ELD)")
+			}
 			return nil
 		}
 		if err := c.PairVerify(ctx); err != nil {
@@ -312,6 +320,16 @@ func (c *AirPlayClient) completeRawSetupAndVerify(ctx context.Context, serverPub
 // all errors after setup must surface to the caller instead of changing
 // protocols.
 func isUnsupportedHAPPairSetup(err error) bool {
+	var setupErr *pairSetupProtocolError
+	if errors.As(err, &setupErr) {
+		// Some third-party receivers parse the transient HAP M1 and return an
+		// SRP challenge, but cannot complete transient SRP: M4 reports
+		// authentication even though no credential is configured. Treat that
+		// response as protocol negotiation, not as a request for a PIN. A real
+		// HAP authentication failure at M2 remains terminal, and raw pairing
+		// still has to complete before the protocol changes.
+		return setupErr.state == 4 && setupErr.code == 2
+	}
 	var statusErr *HTTPStatusError
 	if !errors.As(err, &statusErr) {
 		return false
@@ -324,6 +342,15 @@ func isUnsupportedHAPPairSetup(err error) bool {
 	}
 }
 
+type pairSetupProtocolError struct {
+	state int
+	code  int
+}
+
+func (e *pairSetupProtocolError) Error() string {
+	return fmt.Sprintf("pair-setup M%d error: %s (%d)", e.state, pairingErrorName(e.code), e.code)
+}
+
 // rawPairSetup sends a 32-byte Ed25519 public key to /pair-setup and expects
 // a 32-byte server Ed25519 public key back. This is the original AirPlay
 // transient pair-setup protocol.
@@ -600,8 +627,8 @@ func (c *AirPlayClient) completeSRPExchange(ctx context.Context, pin string, sal
 	}
 
 	m4 := tlv8Decode(m4Bytes)
-	if errTLV, ok := m4[tlvError]; ok {
-		return fmt.Errorf("pair-setup M4 error: %d", errTLV[0])
+	if errTLV, ok := m4[tlvError]; ok && len(errTLV) != 0 {
+		return &pairSetupProtocolError{state: 4, code: int(errTLV[0])}
 	}
 
 	// Verify server proof: H(A, M1, K) — A unpadded
diff --git a/internal/airplay/pairing_negotiation_test.go b/internal/airplay/pairing_negotiation_test.go
index ac759bd..aeb6d71 100644
--- a/internal/airplay/pairing_negotiation_test.go
+++ b/internal/airplay/pairing_negotiation_test.go
@@ -163,6 +163,102 @@ func TestTransientPairingDoesNotFallbackOnTLVBackoff(t *testing.T) {
 	}, "persisted")
 }
 
+func TestTransientPairingFallsBackToRawAfterTransientSRPAuthenticationRejection(t *testing.T) {
+	state := newReceiverPairingTestState(t, "", newReceiverControllerStore())
+	clientConn, serverConn := newPairingNegotiationPipe(t)
+	defer clientConn.Close()
+	defer serverConn.Close()
+
+	client := &AirPlayClient{
+		conn:      clientConn,
+		PairingID: "12345678-1234-4234-8234-123456789abc",
+		info:      &ReceiverInfo{Features: testModernPairingFeatures},
+	}
+	serverDone := make(chan error, 1)
+	go func() {
+		reader := bufio.NewReader(serverConn)
+		request, err := readRTSPTestRequest(reader)
+		if err != nil {
+			serverDone <- err
+			return
+		}
+		if err := requireTransientHAPM1(request); err != nil {
+			serverDone <- err
+			return
+		}
+		response, err := state.pairSetup(request.body)
+		if err != nil {
+			serverDone <- err
+			return
+		}
+		if err := writeRTSPTestResponse(serverConn, 200, nil, response); err != nil {
+			serverDone <- err
+			return
+		}
+
+		request, err = readRTSPTestRequest(reader)
+		if err != nil {
+			serverDone <- err
+			return
+		}
+		if request.uri != "/pair-setup" {
+			serverDone <- fmt.Errorf("M3 path = %q, want /pair-setup", request.uri)
+			return
+		}
+		m4 := tlv8EncodeOrdered([]tlv8Item{
+			{Tag: tlvState, Value: []byte{4}},
+			{Tag: tlvError, Value: []byte{2}},
+		})
+		if err := writeRTSPTestResponse(serverConn, 200, nil, m4); err != nil {
+			serverDone <- err
+			return
+		}
+
+		serverDone <- serveRawPairingRequests(reader, serverConn, state)
+	}()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	if err := client.Pair(ctx, ""); err != nil {
+		t.Fatalf("Pair: %v", err)
+	}
+	waitPairingTestServer(t, serverDone)
+	if client.pairingProtocol != pairingProtocolRaw {
+		t.Fatalf("pairing protocol = %d, want raw", client.pairingProtocol)
+	}
+	if client.info == nil || !client.info.rawAACELD {
+		t.Fatal("M4 rejection followed by raw pairing did not record the raw AAC-ELD media profile")
+	}
+}
+
+func serveRawPairingRequests(reader *bufio.Reader, conn net.Conn, state *receiverPairingState) error {
+	for requestIndex := 0; requestIndex < 3; requestIndex++ {
+		request, err := readRTSPTestRequest(reader)
+		if err != nil {
+			return err
+		}
+		var response []byte
+		switch request.uri {
+		case "/pair-setup":
+			if len(request.body) != 32 {
+				return fmt.Errorf("raw pair-setup body = %d bytes", len(request.body))
+			}
+			response, err = state.pairSetup(request.body)
+		case "/pair-verify":
+			response, err = state.pairVerify(request.body)
+		default:
+			return fmt.Errorf("raw request %d path = %q", requestIndex+1, request.uri)
+		}
+		if err != nil {
+			return err
+		}
+		if err := writeRTSPTestResponse(conn, 200, nil, response); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func TestTransientPairingDoesNotFallbackAfterHAPVerificationStarts(t *testing.T) {
 	state := newReceiverPairingTestState(t, "", newReceiverControllerStore())
 	clientConn, serverConn := newPairingNegotiationPipe(t)
-- 
2.55.0

@3rd3

3rd3 commented Aug 22, 2026

Copy link
Copy Markdown
Author

@omarroth Thank You!
I had to fix some things to make it work again. Instead of new PRs, I simply added two comments with patches for each PR (based on the latest dev branch).

Two notes:

  1. Shouldn't AirServer work out of the box, without special compiler flags or command line arguments? After all, it likely conforms with the AirPlay specs.
  2. The delay is quite bad on all models I tested; around 600-700ms, even at 720p screen resolution. But it works.

@3rd3

3rd3 commented Aug 22, 2026

Copy link
Copy Markdown
Author

I guess I was able to answer my own question:

  • cgo complicates cross-compilation.
  • Some platforms don't package FDK-AAC.
  • FDK-AAC has licensing considerations.
  • Many users only need Apple TV/ALAC support.

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.

@omarroth

Copy link
Copy Markdown
Owner

I pushed a couple more changes to dev that should improve latency for you (it's basically re-implementation of the automatic latency control in the official implementation). Looking through the patches you provided, I would really like to avoid any model-specific logic to ensure consistent behavior between receivers.

If the FDK or raw ELD stuff is still required I will look more into reviewing those changes.

@3rd3

3rd3 commented Aug 28, 2026

Copy link
Copy Markdown
Author

@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 AppleTV5,3.

AirServer capability findings

Information missing from AirServer

macOS AirServer does not provide the more specific:

  • supportedFormats.screenStream

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:

  • audioFormats
  • audioLatencies
  • displays
  • features
  • pk

Its audioFormats entries include:

  • type=100
  • type=101
  • audioInputFormats=0x03fffffc
  • audioOutputFormats=0x03fffffc

The type values represent audio stream/use categories, not codec IDs. Their authoritative names have not been established from available evidence.

Information missing from doubletake

Doubletake logged /info.audioFormats, but did not decode it into ReceiverInfo or use it during codec selection.

The fix added structured parsing for:

  • type
  • audioInputFormats
  • audioOutputFormats

This provides a secondary capability source when the more specific supportedFormats.screenStream field is absent.

The selection order should remain:

  1. Pairing-negotiated AirTame raw AAC-ELD profile, where applicable.
  2. Explicit supportedFormats.screenStream, when present.
  3. Legacy audioFormats.audioOutputFormats, when screen formats are absent.
  4. Conservative fallback.

No selection should depend on AppleTV5,3, the receiver name, IP address, or an exact feature mask.

Meaning of the format bits

AirServer advertises:

audioOutputFormats = 0x03fffffc

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:

Value Meaning Result
0x00040000 ALAC, 44.1 kHz stereo Accepted and now clean
0x00400000 AAC-LC, 44.1 kHz stereo Accepted and clean, but delayed
0x01000000 AAC-ELD, 44.1 kHz stereo Accepted, clean, and immediate

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:

  • codec or sample representation,
  • sample rate,
  • channel count,
  • and possibly other format properties.

Therefore, 0x03fffffc does not mean “AAC-ELD only.” It advertises many audio formats. The native iPhone capture separately proves that the iPhone selected AAC-ELD for mirroring.

Codec results

AAC-ELD

Working configuration:

  • ct=8
  • audioFormat=0x01000000
  • 44.1 kHz stereo
  • spf=480
  • redundantAudio=2
  • rolling redundancy [N-2, N-1, N]
  • AES-CBC RTP encryption using the FairPlay session key
  • latencyMax=7497

Result:

  • Clean audio
  • Immediate startup
  • Requires the optional FDK-AAC encoder, with upstream licensing concerns

AAC-LC

Working configuration:

  • ct=4
  • audioFormat=0x00400000
  • 44.1 kHz stereo
  • spf=1024
  • AAC object type 2
  • MPEG-4 raw access units without ADTS

Result:

  • Clean audio
  • AirServer creates its aac decoder successfully
  • Consistent startup delay of approximately 5.9 seconds

The delay was unchanged by:

  • single-send versus rolling redundancy,
  • randomized RTP timestamp and sequence number,
  • no explicit ASC,
  • ASC 12 10,
  • configuration 12 10 00 00.

The delay closely matches 256 AAC-LC frames:

$$256 \times \frac{1024}{44100} \approx 5.94\text{ seconds}$$

This appears to be receiver-side codec buffering.

ALAC

Working configuration:

  • ct=2
  • audioFormat=0x00040000
  • 44.1 kHz stereo
  • spf=352
  • handcrafted verbatim ALAC frames
  • AES-CBC RTP encryption

The ALAC encoder itself was not the original problem. Its verbatim frame header and frame size were compared against FFmpeg:

  • Doubletake prefix: 20 00 12 00 00 02 c0
  • FFmpeg verbatim ALAC prefix: the same
  • Doubletake frame size: 1416 bytes
  • FFmpeg verbatim frame size: 1416 bytes

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:

  • Single-send ALAC: clean, approximately 2–2.5-second startup.
  • Rolling [N-2, N-1, N] redundancy: clean, with a similar startup delay.
  • The old eight-frame/interleaved retransmission behavior produced garbage.

The delay again approximates 256 codec frames:

$$256 \times \frac{352}{44100} \approx 2.04\text{ seconds}$$

Allowing for receiver startup overhead, this matches observation.

Practical conclusion

  • AirServer does not advertise only AAC-ELD
  • It omits the screen-specific capability field but advertises a broad legacy audio-format mask.
  • AAC-ELD is the native, immediate, low-latency choice by Apple devices.
  • AAC-LC works but incurs about 5.9 seconds of receiver buffering.
  • ALAC now works cleanly without an external codec dependency and starts after about 2–2.5 seconds.
  • ALAC is therefore the strongest licensing-friendly option, provided doubletake avoids the old interleaved retransmission pattern.

AirServer model comparison

Receiver Model AirPlay version audioFormats types Notable advertised fields
AirTame 2 AppleTV5,3 375.3 100, 101, 102 No displays or supportedFormats
macOS AirServer AppleTV5,3 220.68 100, 101 Includes displays; no supportedFormats.screenStream

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 (AlacDecoder ... -108), while its known working MacBook path uses AAC (codec 7).

ALAC could still work with an as-yet-unknown AirTame-specific descriptor, but no available capture or receiver result supports that. AAC-ELD ct=7 remains the only evidence-backed path.

Patch

From 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.0

Changes 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)
 	}
 }
 

@3rd3
3rd3 marked this pull request as draft August 28, 2026 16:58
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.

2 participants