Skip to content
Open
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
20 changes: 19 additions & 1 deletion cmd/doubletake/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ import (
"doubletake/internal/daemon"
)

func parseXID(s string) (uint64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
return strconv.ParseUint(s, 0, 64) // accepts decimal or 0xhex
}

// parsePortRange parses a "min-max" string into inclusive port bounds.
// An empty string returns (0, 0, nil) meaning "let the OS pick".
func parsePortRange(s string) (int, int, error) {
Expand Down Expand Up @@ -66,6 +74,8 @@ func main() {
debug := flag.Bool("debug", false, "Enable verbose debug logging")
daemonize := flag.Bool("daemonize", false, "Run as background daemon with Unix socket control interface")
socketPath := flag.String("socket", daemon.DefaultSocketPath(), "Unix socket path for daemon control interface")
x11WindowID := flag.String("x11-window-id", "", "X11 window id to capture, decimal or 0xhex")
x11WindowName := flag.String("x11-window-name", "", "X11 window name to capture; prefer -x11-window-id")
flag.Parse()

airplay.SetTargetLatency(time.Duration(*targetLatencyMs) * time.Millisecond)
Expand All @@ -80,6 +90,12 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

xid, err := parseXID(*x11WindowID)
if err != nil {
log.Fatalf("invalid -x11-window-id: %v", err)
}


sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
Expand Down Expand Up @@ -275,14 +291,16 @@ func main() {
Bitrate: *bitrate,
HWAccel: *hwaccel,
})
if err != nil {
if err != nil {
log.Fatalf("test capture failed: %v", err)
}
} else {
captureCfg := airplay.CaptureConfig{
FPS: *fps,
Bitrate: *bitrate,
HWAccel: *hwaccel,
X11WindowID: xid,
X11WindowName: *x11WindowName,
}
var err error
capture, err = airplay.StartCapture(ctx, captureCfg)
Expand Down
207 changes: 176 additions & 31 deletions internal/airplay/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import (
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync"
"time"

"golang.org/x/sys/unix"
aeadchacha20poly1305 "github.com/aead/chacha20poly1305"
)

Expand Down Expand Up @@ -176,47 +177,190 @@ func (ac *AudioCapture) ReadFrame(buf []byte) (int, error) {
return n, nil
}

// DrainStale discards any PCM that buffered in the OS pipe between capture
// start and the first read. The capture pipeline starts producing audio
// immediately, but streaming does not begin until the first video frame is
// sent; during that gap the kernel pipe accumulates a FIFO backlog that would
// otherwise be read in order forever, leaving every frame permanently stale and
// audio lagging video. Draining once just before the read loop starts streaming
// from the freshest sample. It removes whatever backlog actually accumulated —
// no fixed latency value is assumed.
func (ac *AudioCapture) DrainStale() {
type deadlineReader interface {
SetReadDeadline(t time.Time) error
func bytesAvailable(f *os.File) int {
available, err := unix.IoctlGetInt(int(f.Fd()), unix.TIOCINQ)
if err != nil {
dbg("[AUDIO] TIOCINQ failed: %v", err)
return 0
}
dr, ok := ac.pcmPipe.(deadlineReader)
if !ok {
return available
}

func (ac *AudioCapture) DropAudioBacklog(keepFrames int) {
f, ok := ac.pcmPipe.(*os.File)
if !ok || f == nil {
return
}
buf := make([]byte, 32*1024)
var discarded int
for {
// Re-arm a short idle timeout each read: while a backlog exists, reads
// return buffered data immediately; once the pipe is empty the read
// blocks and this deadline fires before the next live frame (~8ms)
// arrives, ending the drain. This is a poll timeout, not a latency.
if err := dr.SetReadDeadline(time.Now().Add(2 * time.Millisecond)); err != nil {

const spf = 352
const channels = 2
const bytesPerSample = 2
const frameBytes = spf * channels * bytesPerSample

keep := keepFrames * frameBytes

available := bytesAvailable(f)
if available <= 0 {
return
}
// var available int
// _, _, errno := syscall.Syscall(
// syscall.SYS_IOCTL,
// f.Fd(),
// uintptr(syscall.FIONREAD),
// uintptr(unsafe.Pointer(&available)),
// )
// if errno != 0 || available <= keep {
// return
// }

drop := available - keep
drop -= drop % frameBytes
if drop <= 0 {
return
}

buf := make([]byte, frameBytes)
dropped := 0

for dropped < drop {
want := drop - dropped
if want > len(buf) {
want = len(buf)
}

n, err := ac.pcmPipe.Read(buf[:want])
dropped += n
if err != nil || n == 0 {
break
}
n, err := ac.pcmPipe.Read(buf)
discarded += n
}

if dropped > 0 {
const bytesPerSecond = 44100 * 2 * 2
dbg("[AUDIO] dropped %d stale bytes (~%.0fms), kept %d frames",
dropped,
float64(dropped)/bytesPerSecond*1000,
keepFrames,
)
}
}

func (ac *AudioCapture) DrainStale() {
f, ok := ac.pcmPipe.(*os.File)
if !ok || f == nil {
dbg("[AUDIO] DrainStale: pcmPipe is not *os.File")
return
}

// var available int
// _, _, errno := syscall.Syscall(
// syscall.SYS_IOCTL,
// f.Fd(),
// uintptr(syscall.FIONREAD),
// uintptr(unsafe.Pointer(&available)),
// )
// if errno != 0 {
// dbg("[AUDIO] DrainStale: FIONREAD failed: %v", errno)
// return
// }

available := bytesAvailable(f)

if available <= 0 {
dbg("[AUDIO] DrainStale: no startup backlog")
return
}

// Keep only a tiny amount so ReadFrame() has something immediate.
const spf = 352
const channels = 2
const bytesPerSample = 2
const frameBytes = spf * channels * bytesPerSample // 1408 bytes ~= 8ms

keep := 2 * frameBytes // keep ~16ms
drop := available - keep
if drop <= 0 {
dbg("[AUDIO] DrainStale: backlog only %d bytes, keeping it", available)
return
}

// Keep frame alignment.
drop -= drop % frameBytes
if drop <= 0 {
return
}

buf := make([]byte, frameBytes)
dropped := 0

for dropped < drop {
want := drop - dropped
if want > len(buf) {
want = len(buf)
}

n, err := ac.pcmPipe.Read(buf[:want])
dropped += n
if err != nil {
dbg("[AUDIO] DrainStale: read stopped after %d bytes: %v", dropped, err)
break
}
if n == 0 {
break
}
}
// Restore blocking reads for steady-state streaming.
_ = dr.SetReadDeadline(time.Time{})
if discarded > 0 {
const bytesPerSecond = 44100 * 2 * 2 // 44.1kHz, stereo, S16LE
dbg("[AUDIO] drained %d bytes (~%.0fms) of startup backlog before streaming",
discarded, float64(discarded)/bytesPerSecond*1000)
}

const bytesPerSecond = 44100 * 2 * 2
dbg("[AUDIO] drained %d/%d stale bytes (~%.0fms), kept ~%dms",
dropped,
available,
float64(dropped)/bytesPerSecond*1000,
keep*1000/bytesPerSecond,
)
}

// // DrainStale discards any PCM that buffered in the OS pipe between capture
// // start and the first read. The capture pipeline starts producing audio
// // immediately, but streaming does not begin until the first video frame is
// // sent; during that gap the kernel pipe accumulates a FIFO backlog that would
// // otherwise be read in order forever, leaving every frame permanently stale and
// // audio lagging video. Draining once just before the read loop starts streaming
// // from the freshest sample. It removes whatever backlog actually accumulated —
// // no fixed latency value is assumed.
// func (ac *AudioCapture) DrainStale() {
// type deadlineReader interface {
// SetReadDeadline(t time.Time) error
// }
// dr, ok := ac.pcmPipe.(deadlineReader)
// if !ok {
// return
// }
// buf := make([]byte, 32*1024)
// var discarded int
// for {
// // Re-arm a short idle timeout each read: while a backlog exists, reads
// // return buffered data immediately; once the pipe is empty the read
// // blocks and this deadline fires before the next live frame (~8ms)
// // arrives, ending the drain. This is a poll timeout, not a latency.
// if err := dr.SetReadDeadline(time.Now().Add(2 * time.Millisecond)); err != nil {
// break
// }
// n, err := ac.pcmPipe.Read(buf)
// discarded += n
// if err != nil {
// break
// }
// }
// // Restore blocking reads for steady-state streaming.
// _ = dr.SetReadDeadline(time.Time{})
// if discarded > 0 {
// const bytesPerSecond = 44100 * 2 * 2 // 44.1kHz, stereo, S16LE
// dbg("[AUDIO] drained %d bytes (~%.0fms) of startup backlog before streaming",
// discarded, float64(discarded)/bytesPerSecond*1000)
// }
// }

func (ac *AudioCapture) Stop() {
if ac.stopped {
return
Expand Down Expand Up @@ -803,6 +947,7 @@ func (s *MirrorSession) StreamAudio(ctx context.Context, capture *AudioCapture,
default:
}

// TODO: capture.DropAudioBacklog(2)
n, err := capture.ReadFrame(frameBuf)
if err != nil {
if ctx.Err() != nil {
Expand Down
Loading
Loading