133e25849d
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit: Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split along the same seams. The gamepad mode is couch-complete, and now on macOS too (the living-room Mac case), not just iOS/iPadOS: - GamepadSettingsView: a console-style, fully controller-navigable settings screen (X from the launcher) — up/down moves focus, left/right steps values (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a one-line description. Backed by GamepadMenuList, the vertical sibling of GamepadCarousel, and SettingsOptions — the option lists hoisted out of SettingsView statics and shared by the touch, tvOS and gamepad settings. - GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad — field rows open an on-screen controller keyboard (dpad grid, A types, X backspaces, B done); the launcher carousel ends in an Add Host tile, so the dead-end "add one with touch first" empty state is gone. - Launcher polish: contextual hint bar with the pad's real button glyphs, controller name + battery chip, one shared console chrome. - GamepadScreenBackground: an animated aurora (TimelineView-driven drifting blobs in the brand's violet family, breathing radii, slow hue shift, legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a .metal library only bundles reliably in one of the two build systems (SPM vs the xcodeproj's synced folders) these sources compile under. - macOS port: settings/add-host/library present as sized sheets (a macOS sheet takes its content's IDEAL size, and the GeometryReader-driven screens collapsed to nothing), NSScreen-based mode lists, scroll indicators .never (the "always show scroll bars" setting overrides .hidden), tray scrims so scrolled rows dim under the pinned title/hints, extra title clearance, and a PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/ library render-verified live on a real Mac + LAN hosts. - GamepadMenuInput: X button support, and (re)start now snapshots held buttons so a controller handoff press never fires twice (the B that closed the keyboard no longer also cancels the screen underneath). - Cleanups: one "Connection failed" alert in ContentView instead of one per home screen; HostDiscovery.advertises/unsaved shared by both home screens. - host: can_encode_444 stub for the non-Linux/Windows host build (the macOS synthetic-source loopback used by the Swift tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
6.1 KiB
Swift
130 lines
6.1 KiB
Swift
import AVFoundation
|
||
import os
|
||
|
||
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||
final class AudioRing: @unchecked Sendable {
|
||
private var buf: [Float]
|
||
private var readIdx = 0
|
||
private var writeIdx = 0
|
||
private var primed = false
|
||
private var renderQuantum = 0
|
||
private let prefill: Int
|
||
private let highWater: Int
|
||
private let channels: Int
|
||
private let lock = OSAllocatedUnfairLock()
|
||
|
||
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||
init(capacity: Int, prefill: Int, channels: Int) {
|
||
buf = [Float](repeating: 0, count: capacity)
|
||
self.prefill = prefill
|
||
self.channels = channels
|
||
highWater = prefill * 4
|
||
}
|
||
|
||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
let capacity = buf.count
|
||
// A single write larger than the whole ring would push readIdx PAST writeIdx below
|
||
// (inverting the valid range — corruption). It never happens (one decoded packet is far
|
||
// under capacity), but guard rather than corrupt.
|
||
guard count <= capacity else { return }
|
||
if writeIdx + count - readIdx > capacity {
|
||
readIdx = writeIdx + count - capacity // overflow: drop oldest
|
||
}
|
||
for i in 0..<count {
|
||
buf[(writeIdx + i) % capacity] = samples[i]
|
||
}
|
||
writeIdx += count
|
||
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
|
||
// creeping host-vs-DAC clock skew) never drains on its own — without this, one
|
||
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
|
||
// Shedding down to 2× prefill costs one audible blip instead.
|
||
if writeIdx - readIdx > highWater {
|
||
readIdx = writeIdx - prefill * 2
|
||
}
|
||
}
|
||
|
||
/// Fills `out` completely (silence beyond what's buffered).
|
||
func read(into out: UnsafeMutablePointer<Float>, count: Int) {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
renderQuantum = max(renderQuantum, count)
|
||
let available = writeIdx - readIdx
|
||
if !primed {
|
||
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||
primed = true
|
||
} else {
|
||
for i in 0..<count { out[i] = 0 }
|
||
return
|
||
}
|
||
}
|
||
let n = min(available, count)
|
||
let capacity = buf.count
|
||
for i in 0..<n {
|
||
out[i] = buf[(readIdx + i) % capacity]
|
||
}
|
||
readIdx += n
|
||
if n < count {
|
||
for i in n..<count { out[i] = 0 }
|
||
primed = false // underrun — re-prime before resuming
|
||
}
|
||
}
|
||
}
|
||
|
||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||
/// stereo (the standard layout is correct). For 5.1/7.1 we list explicit channel labels via
|
||
/// `kAudioChannelLayoutTag_UseChannelDescriptions` — preset tags (DTS_5_1 etc.) don't reliably
|
||
/// match Moonlight's order. NB the 7.1 mapping (verified against the WASAPI 0x63F + SPA orderings):
|
||
/// wire idx 4-5 = RL/RR = the WAVE *back* pair → LeftSurround/RightSurround; idx 6-7 = SL/SR = the
|
||
/// WAVE *side* pair → LeftSurroundDirect/RightSurroundDirect. (Using RearSurround* for 6-7 would
|
||
/// swap side/back vs the Windows/Linux clients.)
|
||
func wireChannelLayout(channels: Int) -> AVAudioChannelLayout? {
|
||
let labels: [AudioChannelLabel]
|
||
switch channels {
|
||
case 6:
|
||
labels = [
|
||
kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center,
|
||
kAudioChannelLabel_LFEScreen, kAudioChannelLabel_LeftSurround,
|
||
kAudioChannelLabel_RightSurround,
|
||
]
|
||
case 8:
|
||
labels = [
|
||
kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center,
|
||
kAudioChannelLabel_LFEScreen,
|
||
kAudioChannelLabel_LeftSurround, kAudioChannelLabel_RightSurround, // wire RL/RR (back)
|
||
kAudioChannelLabel_LeftSurroundDirect, kAudioChannelLabel_RightSurroundDirect, // wire SL/SR (side)
|
||
]
|
||
default:
|
||
return nil
|
||
}
|
||
let size = MemoryLayout<AudioChannelLayout>.size
|
||
+ (labels.count - 1) * MemoryLayout<AudioChannelDescription>.stride
|
||
let raw = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: 16)
|
||
defer { raw.deallocate() }
|
||
let layout = raw.bindMemory(to: AudioChannelLayout.self, capacity: 1)
|
||
layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions
|
||
layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0)
|
||
layout.pointee.mNumberChannelDescriptions = UInt32(labels.count)
|
||
// `mChannelDescriptions` is the C variable-length tail array (declared `[1]`, over-allocated
|
||
// above). Scope the pointer with `withUnsafeMutablePointer` — taking `&…mChannelDescriptions`
|
||
// inline yields a pointer valid only for that expression, so building a buffer from it that
|
||
// outlives the call is a dangling-pointer bug. Inside the closure it stays valid while we fill it.
|
||
withUnsafeMutablePointer(to: &layout.pointee.mChannelDescriptions) { tail in
|
||
let descs = UnsafeMutableBufferPointer(start: tail, count: labels.count)
|
||
for (i, lbl) in labels.enumerated() {
|
||
descs[i] = AudioChannelDescription(
|
||
mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0),
|
||
mCoordinates: (0, 0, 0))
|
||
}
|
||
}
|
||
return AVAudioChannelLayout(layout: layout)
|
||
}
|