Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/doubletake/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func main() {

// Start audio capture and streaming unless disabled.
if !*noAudio && session.HasAudio() {
audioCapture, err := airplay.StartAudioCapture(ctx, *testMode)
audioCapture, err := airplay.StartAudioCapture(ctx, *testMode, session.AudioCodec())
if err != nil {
log.Printf("warning: audio capture failed: %v (continuing without audio)", err)
} else {
Expand Down
93 changes: 93 additions & 0 deletions internal/airplay/aac_eld.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package airplay

/*
#cgo pkg-config: fdk-aac
#include <fdk-aac/aacenc_lib.h>

static AACENC_ERROR eld_open(HANDLE_AACENCODER *enc) {
AACENC_ERROR err;
if ((err = aacEncOpen(enc, 0, 2)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_AOT, 39)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_SAMPLERATE, 44100)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_CHANNELMODE, 2)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_CHANNELORDER, 1)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_BITRATE, 128000)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_TRANSMUX, 0)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_SBR_MODE, 0)) != AACENC_OK) return err;
if ((err = aacEncoder_SetParam(*enc, AACENC_GRANULE_LENGTH, 480)) != AACENC_OK) return err;
return aacEncEncode(*enc, NULL, NULL, NULL, NULL);
}

static AACENC_ERROR eld_frame_length(HANDLE_AACENCODER enc, UINT *frameLength) {
AACENC_InfoStruct info;
AACENC_ERROR err = aacEncInfo(enc, &info);
if (err == AACENC_OK) *frameLength = info.frameLength;
return err;
}

static AACENC_ERROR eld_encode(HANDLE_AACENCODER enc, INT_PCM *pcm, INT nSamples, UCHAR *out, INT outSize, INT *nOut) {
AACENC_BufDesc inDesc = {0}, outDesc = {0};
AACENC_InArgs inArgs = {0};
AACENC_OutArgs outArgs = {0};
void *inBufs[1] = {pcm}, *outBufs[1] = {out};
INT inIds[1] = {IN_AUDIO_DATA}, outIds[1] = {OUT_BITSTREAM_DATA};
INT inSizes[1] = {nSamples * (INT)sizeof(INT_PCM)}, outSizes[1] = {outSize};
INT inElSizes[1] = {(INT)sizeof(INT_PCM)}, outElSizes[1] = {1};
inDesc.numBufs = outDesc.numBufs = 1;
inDesc.bufs = inBufs; inDesc.bufferIdentifiers = inIds; inDesc.bufSizes = inSizes; inDesc.bufElSizes = inElSizes;
outDesc.bufs = outBufs; outDesc.bufferIdentifiers = outIds; outDesc.bufSizes = outSizes; outDesc.bufElSizes = outElSizes;
inArgs.numInSamples = nSamples;
AACENC_ERROR err = aacEncEncode(enc, &inDesc, &outDesc, &inArgs, &outArgs);
*nOut = outArgs.numOutBytes;
return err;
}
*/
import "C"

import (
"fmt"
"unsafe"
)

type eldEncoder struct {
enc C.HANDLE_AACENCODER
frameLen int
outBuf []byte
}

func newELDEncoder(_, _, _ int) (*eldEncoder, error) {
var enc C.HANDLE_AACENCODER
if err := C.eld_open(&enc); err != C.AACENC_OK {
if enc != nil {
C.aacEncClose(&enc)
}
return nil, fmt.Errorf("open AAC-ELD encoder: %d", int(err))
}
var frameLen C.UINT
if err := C.eld_frame_length(enc, &frameLen); err != C.AACENC_OK {
C.aacEncClose(&enc)
return nil, fmt.Errorf("read AAC-ELD encoder info: %d", int(err))
}
return &eldEncoder{enc: enc, frameLen: int(frameLen), outBuf: make([]byte, 2048)}, nil
}

func (e *eldEncoder) Encode(pcm, out []byte) (int, error) {
var n C.INT
err := C.eld_encode(e.enc, (*C.INT_PCM)(unsafe.Pointer(&pcm[0])), C.INT(e.frameLen*2),
(*C.UCHAR)(unsafe.Pointer(&e.outBuf[0])), C.INT(len(e.outBuf)), &n)
if err != C.AACENC_OK {
return 0, fmt.Errorf("encode AAC-ELD: %d", int(err))
}
if int(n) > len(out) {
return 0, fmt.Errorf("AAC-ELD frame exceeds buffer")
}
copy(out, e.outBuf[:n])
return int(n), nil
}

func (e *eldEncoder) Close() {
if e != nil && e.enc != nil {
C.aacEncClose(&e.enc)
e.enc = nil
}
}
28 changes: 26 additions & 2 deletions internal/airplay/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type audioChaChaAADMode int

const (
AudioCodecALAC AudioCodec = 2 // ct=2, spf=352, audioFormat=0x40000
AudioCodecAAC AudioCodec = 7 // ct=7, spf=480, audioFormat=0x1000000

audioSecurityLegacyAES audioSecurityMode = iota
audioSecurityChaCha
Expand Down Expand Up @@ -64,6 +65,9 @@ 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 == AudioCodecAAC {
return 7, 480, 0x1000000, 0, int64(latency), latency
}
return 2, 352, 0x40000, 0, int64(latency), latency
}

Expand Down Expand Up @@ -91,11 +95,12 @@ type AudioCapture struct {
waitCh chan struct{}
waitErr error
stopped bool
eld *eldEncoder
}

// StartAudioCapture launches a pipeline that captures system audio (monitor source)
// and feeds raw PCM into the built-in ALAC encoder.
func StartAudioCapture(ctx context.Context, testTone bool) (*AudioCapture, error) {
func StartAudioCapture(ctx context.Context, testTone bool, codec AudioCodec) (*AudioCapture, error) {
captureCtx, cancel := context.WithCancel(ctx)

// Detect audio source
Expand Down Expand Up @@ -124,6 +129,14 @@ func StartAudioCapture(ctx context.Context, testTone bool) (*AudioCapture, error
cancel: cancel,
waitCh: make(chan struct{}),
}
if codec == AudioCodecAAC {
var err error
ac.eld, err = newELDEncoder(44100, 480, 128000)
if err != nil {
cancel()
return nil, err
}
}

gstArgs := []string{"--quiet"}
gstArgs = append(gstArgs, srcArgs...)
Expand Down Expand Up @@ -170,6 +183,13 @@ func (ac *AudioCapture) ReadFrame(buf []byte) (int, error) {
return 0, io.EOF
default:
}
if ac.eld != nil {
pcm := make([]byte, ac.eld.frameLen*2*2)
if _, err := io.ReadFull(ac.pcmPipe, pcm); err != nil {
return 0, err
}
return ac.eld.Encode(pcm, buf)
}

const spf = 352
const channels = 2
Expand Down Expand Up @@ -229,6 +249,9 @@ func (ac *AudioCapture) Stop() {
return
}
ac.stopped = true
if ac.eld != nil {
ac.eld.Close()
}
if ac.cancel != nil {
ac.cancel()
}
Expand Down Expand Up @@ -420,7 +443,8 @@ func (s *MirrorSession) setupAudioStream(dataPort, controlPort int, aesKey, aesI
}
}

spf := uint16(352)
_, codecSPF, _, _, _, _ := AudioCodec(ct).Info()
spf := uint16(codecSPF)
latencySamples := audioLatencySamplesForCodec(ct, latencyOverride)

// Apple senders use SSRC=0 for mirroring audio RTP.
Expand Down
17 changes: 17 additions & 0 deletions internal/airplay/boot_clock_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build linux

package airplay

import (
"time"

"golang.org/x/sys/unix"
)

func bootRelativeNow() time.Duration {
var ts unix.Timespec
if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &ts); err == nil {
return time.Duration(ts.Sec)*time.Second + time.Duration(ts.Nsec)
}
return time.Since(appStartTime)
}
9 changes: 9 additions & 0 deletions internal/airplay/boot_clock_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !linux

package airplay

import "time"

func bootRelativeNow() time.Duration {
return time.Since(appStartTime)
}
7 changes: 7 additions & 0 deletions internal/airplay/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,13 @@ func startX11Capture(ctx context.Context, cfg CaptureConfig) (*ScreenCapture, er
}

beforeConvert := []gstStage{frameRateStage(fps), lowLatencyVideoQueueStage()}
if cfg.MaxWidth > 0 && cfg.MaxHeight > 0 {
// Fold size into the existing videoconvert caps. A second video/x-raw
// filter is parsed by gst-launch as element "video" (the '/' pad syntax).
beforeConvert = append(beforeConvert, gstStage{"videoscale", "add-borders=true"})
encoder.rawFormat = fmt.Sprintf("%s,width=%d,height=%d", encoder.rawFormat, cfg.MaxWidth, cfg.MaxHeight)
dbg("[CAPTURE] scaling X11 capture to %dx%d", cfg.MaxWidth, cfg.MaxHeight)
}
gstArgs := buildGstVideoPipeline(ximageSrcArgs, beforeConvert, nil, encoder)

dbg("[CAPTURE] gst-launch-1.0 (x11) %s", strings.Join(gstArgs, " "))
Expand Down
26 changes: 22 additions & 4 deletions internal/airplay/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,15 @@ type DisplayInfo struct {
// codec header uses this as the presentation (display) size so the receiver
// can center/pillarbox content whose aspect ratio differs from the display.
func (i *ReceiverInfo) DisplaySize() (int, int) {
if i == nil || len(i.Displays) == 0 {
if i == nil {
return 0, 0
}
// AirTame / AirServer omit displays[] but drive a 1080p Vivante framebuffer.
// Encoding the sender desktop at native 2560x1440 stays black on that VPU.
if len(i.Displays) == 0 && i.looksLikeAirServerClone() {
return 1920, 1080
}
if len(i.Displays) == 0 {
return 0, 0
}
d := i.Displays[0]
Expand Down Expand Up @@ -805,15 +813,26 @@ func (mc *mirrorCipher) EncryptFrame(payload []byte) []byte {
// Step 1: XOR prefix bytes using cached keystream from previous frame's
// trailing partial block (matches receiver's og buffer usage).
if mc.nextCryptCount > 0 {
n := mc.nextCryptCount
available := mc.nextCryptCount
n := available
if n > inputLen {
n = inputLen
}
ogStart := 16 - mc.nextCryptCount
ogStart := 16 - available
for i := 0; i < n; i++ {
out[i] = payload[i] ^ mc.og[ogStart+i]
}
pos = n
if n < available {
// Keep the unused suffix in the same right-aligned layout used by
// the next frame. Small VCL payloads can consume this cached block
// over more than one frame.
remaining := available - n
copy(mc.og[16-remaining:], mc.og[ogStart+n:])
mc.nextCryptCount = remaining
return out
}
mc.nextCryptCount = 0
}

// Step 2: Advance CTR to next 16-byte boundary (aes_ctr_start_fresh_block).
Expand All @@ -835,7 +854,6 @@ func (mc *mirrorCipher) EncryptFrame(payload []byte) []byte {

// Step 4: Handle trailing partial block.
restLen := remaining % 16
mc.nextCryptCount = 0
if restLen > 0 {
// Pad input to 16 bytes, encrypt full block, use first restLen bytes.
var padded [16]byte
Expand Down
12 changes: 12 additions & 0 deletions internal/airplay/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,23 @@ func supportsTransientPairing(features uint64) bool {
return features&(FeatureTransientPairing|FeatureSystemPairing) != 0
}

// 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
}

// usesModernPairing reports whether the receiver can use the first-party
// CoreUtils/HAP profile directly. Third-party receivers retain HKP type 3 and
// legacy session setup even when they copy the modern pairing feature bits.
func (i *ReceiverInfo) usesModernPairing() bool {
return i != nil &&
!i.looksLikeAirServerClone() &&
i.Features&featureCoreUtilsPairingMask != 0 &&
i.Features&featureThirdPartyReceiverMask == 0
}
Expand Down
Loading
Loading