Skip to content
Merged
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
11 changes: 10 additions & 1 deletion Examples/CLIPlayer/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Examples/CLIPlayer/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ let package = Package(
.macOS(.v14)
],
dependencies: [
.package(path: "../..")
.package(name: "SendspinKit", path: "../..")
],
targets: [
.executableTarget(
Expand Down
21 changes: 15 additions & 6 deletions Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ final class CLIPlayer {
}
}()

private static func playerConfig(volumeMode: VolumeMode) throws -> PlayerConfiguration {
private static func playerConfig(volumeMode: VolumeMode, outputPolicy: OutputSampleRatePolicy) throws -> PlayerConfiguration {
try PlayerConfiguration(
bufferCapacity: 2_097_152, // 2MB buffer
supportedFormats: supportedFormats,
volumeMode: volumeMode
volumeMode: volumeMode,
outputSampleRatePolicy: outputPolicy
)
}

Expand All @@ -78,6 +79,7 @@ final class CLIPlayer {
clientName: String,
useTUI: Bool = true,
volumeMode: VolumeMode = .software,
outputPolicy: OutputSampleRatePolicy = .preferCurrentOutput,
enablePairing: Bool = false
) async throws {
// Simple startup banner before TUI takes over
Expand All @@ -92,7 +94,7 @@ final class CLIPlayer {
}

// Create client
let config = try Self.playerConfig(volumeMode: volumeMode)
let config = try Self.playerConfig(volumeMode: volumeMode, outputPolicy: outputPolicy)
// Ephemeral demo device: identity and pairing state vanish when the process exits.
let device = SendspinDevice.ephemeral()
let pairing: PairingPresentation = enablePairing ? .display : .tokenOnly
Expand All @@ -111,7 +113,7 @@ final class CLIPlayer {
)
self.client = client

fputs("[CONFIG] Volume mode: \(volumeMode) pairing=\(enablePairing)\n", stderr)
fputs("[CONFIG] Volume mode: \(volumeMode) outputPolicy=\(outputPolicy.rawValue) pairing=\(enablePairing)\n", stderr)

// Start event monitoring
eventTask = Task {
Expand Down Expand Up @@ -449,12 +451,19 @@ final class CLIPlayer {
/// Listen for incoming server connections (server-initiated path).
/// Advertises via mDNS and waits for servers to connect.
@MainActor
func listen(port: UInt16, clientName: String, useTUI: Bool = true, volumeMode: VolumeMode = .software, enablePairing: Bool = false) async throws {
func listen(
port: UInt16,
clientName: String,
useTUI: Bool = true,
volumeMode: VolumeMode = .software,
outputPolicy: OutputSampleRatePolicy = .preferCurrentOutput,
enablePairing: Bool = false
) async throws {
print("🎵 Sendspin CLI Player (Listen Mode)")
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print("Advertising on port \(port)...")

let config = try Self.playerConfig(volumeMode: volumeMode)
let config = try Self.playerConfig(volumeMode: volumeMode, outputPolicy: outputPolicy)
// Ephemeral demo device: identity and pairing state vanish when the process exits.
let device = SendspinDevice.ephemeral()
let pairing: PairingPresentation = enablePairing ? .display : .tokenOnly
Expand Down
26 changes: 24 additions & 2 deletions Examples/CLIPlayer/Sources/CLIPlayer/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var enableTUI = true
var listenMode = false
var listenPort: UInt16 = 8928
var volumeMode: VolumeMode = .software
var outputPolicy: OutputSampleRatePolicy = .preferCurrentOutput
var enablePairing = false

var argIndex = 1
Expand All @@ -29,6 +30,20 @@ while argIndex < args.count {
listenPort = port
argIndex += 1
}
} else if arg == "--output-policy" {
guard argIndex + 1 < args.count else {
print("--output-policy requires: prefer, preserve, require")
exit(1)
}
argIndex += 1
switch args[argIndex] {
case "prefer": outputPolicy = .preferCurrentOutput
case "preserve": outputPolicy = .preserveFormatOrder
case "require": outputPolicy = .requireCurrentOutput
default:
print("Unknown output policy '\(args[argIndex])'. Use: prefer, preserve, require")
exit(1)
}
} else if arg == "--volume-mode" {
if argIndex + 1 < args.count {
argIndex += 1
Expand Down Expand Up @@ -92,7 +107,7 @@ sigintSource.resume()
do {
if listenMode {
// Server-initiated: advertise via mDNS and wait for servers to connect
try await player.listen(port: listenPort, clientName: clientName, useTUI: enableTUI, enablePairing: enablePairing)
try await player.listen(port: listenPort, clientName: clientName, useTUI: enableTUI, outputPolicy: outputPolicy, enablePairing: enablePairing)
} else {
// Client-initiated: discover or connect to provided server URL
if serverURL == nil {
Expand Down Expand Up @@ -120,7 +135,14 @@ do {
print("No server URL available")
exit(1)
}
try await player.run(serverURL: url, clientName: clientName, useTUI: enableTUI, volumeMode: volumeMode, enablePairing: enablePairing)
try await player.run(
serverURL: url,
clientName: clientName,
useTUI: enableTUI,
volumeMode: volumeMode,
outputPolicy: outputPolicy,
enablePairing: enablePairing
)
}
} catch {
print("Fatal error: \(error)")
Expand Down
2 changes: 1 addition & 1 deletion Examples/ClockSyncDiagnostics/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ let package = Package(
name: "ClockSyncDiagnostics",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../.."),
.package(name: "SendspinKit", path: "../.."),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0")
],
targets: [
Expand Down
2 changes: 1 addition & 1 deletion Examples/ControllerClient/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ let package = Package(
name: "ControllerClient",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../.."),
.package(name: "SendspinKit", path: "../.."),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0")
],
targets: [
Expand Down
2 changes: 1 addition & 1 deletion Examples/DiscoveryExample/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ let package = Package(
name: "DiscoveryExample",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../.."),
.package(name: "SendspinKit", path: "../.."),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0")
],
targets: [
Expand Down
2 changes: 1 addition & 1 deletion Examples/ErrorRecovery/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ let package = Package(
name: "ErrorRecovery",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../.."),
.package(name: "SendspinKit", path: "../.."),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0")
],
targets: [
Expand Down
2 changes: 1 addition & 1 deletion Examples/MetadataClient/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ let package = Package(
name: "MetadataClient",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../.."),
.package(name: "SendspinKit", path: "../.."),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0")
],
targets: [
Expand Down
2 changes: 1 addition & 1 deletion Examples/MultiCodecPlayer/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ let package = Package(
name: "MultiCodecPlayer",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../.."),
.package(name: "SendspinKit", path: "../.."),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0")
],
targets: [
Expand Down
2 changes: 1 addition & 1 deletion Examples/VisualizerClient/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ let package = Package(
name: "VisualizerClient",
platforms: [.macOS(.v14)],
dependencies: [
.package(path: "../..")
.package(name: "SendspinKit", path: "../..")
],
targets: [
.target(
Expand Down
30 changes: 30 additions & 0 deletions Sources/SendspinKit/Audio/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ actor AudioEngine {
private let output: any AudioOutput
private let audioScheduler: AudioScheduler
private let clock: any ClockSyncProtocol
private var lastWireChunkTimestamp: Int64?
private var formatChangeLastWireTimestamp: Int64?

// Command ingress
private let _commandsSink: DataPlaneSink
Expand Down Expand Up @@ -853,12 +855,28 @@ actor AudioEngine {
Self.rebase(&startupReleaseDeferredChunks, from: oldDelayUs, to: newDelayUs)
}

private func recordWireChunkTimestamp(_ timestamp: Int64) async {
if let oldTimestamp = formatChangeLastWireTimestamp {
let serverNow = await clock.localTimeToServer(MonotonicClock.absoluteMicroseconds())
Log.audio.debug(
"""
Format-change first chunk: lastOld=\(oldTimestamp, privacy: .public) \
firstNew=\(timestamp, privacy: .public) delta=\(timestamp - oldTimestamp, privacy: .public) \
serverNow=\(serverNow, privacy: .public)
"""
)
formatChangeLastWireTimestamp = nil
}
lastWireChunkTimestamp = timestamp
}

/// Schedule a chunk for playback.
private func applyChunk(data: Data, ts: Int64, generation: UInt64?, routeEpoch: UInt64? = nil) async {
if let generation, generation < streamGeneration {
return
}

await recordWireChunkTimestamp(ts)
let chunkGeneration = generation ?? streamGeneration
guard failedDecoderGeneration != chunkGeneration else { return }
do {
Expand Down Expand Up @@ -1167,6 +1185,13 @@ actor AudioEngine {

/// Discard the old output immediately when a route change invalidates its PCM.
private func applyRouteInvalidatedFormatChange(format: AudioFormatSpec, codecHeader: Data?) async {
if let oldTimestamp = lastWireChunkTimestamp {
formatChangeLastWireTimestamp = oldTimestamp
let serverNow = await clock.localTimeToServer(MonotonicClock.absoluteMicroseconds())
Log.audio.debug(
"Route-invalidated format-change announcement: lastOld=\(oldTimestamp, privacy: .public) serverNow=\(serverNow, privacy: .public)"
)
}
cancelStartupDeadline()
startupBuffer = nil
startupFormat = nil
Expand Down Expand Up @@ -1204,6 +1229,11 @@ actor AudioEngine {
await applyStreamStart(format: format, codecHeader: codecHeader)
return
}
if let oldTimestamp = lastWireChunkTimestamp {
formatChangeLastWireTimestamp = oldTimestamp
let serverNow = await clock.localTimeToServer(MonotonicClock.absoluteMicroseconds())
Log.audio.debug("Format-change announcement: lastOld=\(oldTimestamp, privacy: .public) serverNow=\(serverNow, privacy: .public)")
}
streamGeneration = generation
formatBoundaries[generation] = FormatBoundary(format: format, codecHeader: codecHeader, startGeneration: startGeneration)
chunkTimingFormat = format
Expand Down
12 changes: 12 additions & 0 deletions Sources/SendspinKit/Audio/AudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,18 @@ actor AudioPlayer {
let padFrames: Int64
if let localPlayTime {
let gapUs = localPlayTime - nowAbsolute - pipelineDelayUs
if gapUs < 0 {
let ringBacklogFrames = state.pcmRingBuffer.availableToRead / state.frameSize
Log.audio
.debug(
"""
First-frame negative gap: localLead=\(localPlayTime - nowAbsolute, privacy: .public) \
pipelineDelayUs=\(pipelineDelayUs, privacy: .public) \
framesInFlight=\(framesInFlight, privacy: .public) \
ringBacklogFrames=\(ringBacklogFrames, privacy: .public)
"""
)
}
state.startupLateUs = max(0, -gapUs)
padFrames = min(
max(gapUs * sampleRate / 1_000_000, 0),
Expand Down
80 changes: 80 additions & 0 deletions scripts/set-output-rate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env swift
// ABOUTME: Changes the default output device's nominal sample rate for route-change tests.
// ABOUTME: Validates supported rates and prints the previous rate for restoration.

import CoreAudio
import Foundation

struct RateError: Error, CustomStringConvertible {
let description: String
}

func check(_ status: OSStatus, _ operation: String) throws {
guard status == noErr else {
throw RateError(description: "\(operation) failed: OSStatus \(status)")
}
}

func address(_ selector: AudioObjectPropertySelector) -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
}

func readRate(_ device: AudioDeviceID) throws -> Float64 {
var property = address(kAudioDevicePropertyNominalSampleRate)
var rate: Float64 = 0
var size = UInt32(MemoryLayout<Float64>.size)
try check(AudioObjectGetPropertyData(device, &property, 0, nil, &size, &rate), "Read nominal rate")
return rate
}

do {
guard CommandLine.arguments.count == 2,
let requested = Float64(CommandLine.arguments[1]), requested.isFinite, requested > 0
else {
throw RateError(description: "Usage: set-output-rate.swift <hz>")
}
var defaultOutput = address(kAudioHardwarePropertyDefaultOutputDevice)
var device = AudioDeviceID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
try check(
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &defaultOutput, 0, nil, &size, &device),
"Resolve default output"
)
guard device != kAudioObjectUnknown else { throw RateError(description: "No default output device") }
let previous = try readRate(device)
var available = address(kAudioDevicePropertyAvailableNominalSampleRates)
var rangesSize: UInt32 = 0
try check(AudioObjectGetPropertyDataSize(device, &available, 0, nil, &rangesSize), "Read supported rates size")
var ranges = [AudioValueRange](
repeating: AudioValueRange(mMinimum: 0, mMaximum: 0),
count: Int(rangesSize) / MemoryLayout<AudioValueRange>.stride
)
try ranges.withUnsafeMutableBytes { bytes in
guard let base = bytes.baseAddress else { throw RateError(description: "No supported rates reported") }
try check(AudioObjectGetPropertyData(device, &available, 0, nil, &rangesSize, base), "Read supported rates")
}
guard ranges.contains(where: { requested >= $0.mMinimum && requested <= $0.mMaximum }) else {
let supported = ranges.map { "\($0.mMinimum)...\($0.mMaximum)" }.joined(separator: ", ")
throw RateError(description: "Requested rate \(requested) is unsupported; previous=\(previous); supported=\(supported)")
}
var nominal = address(kAudioDevicePropertyNominalSampleRate)
var rate = requested
try check(AudioObjectSetPropertyData(device, &nominal, 0, nil, UInt32(MemoryLayout<Float64>.size), &rate), "Set nominal rate")
let deadline = ProcessInfo.processInfo.systemUptime + 2
var observed = try readRate(device)
while observed != requested, ProcessInfo.processInfo.systemUptime < deadline {
Thread.sleep(forTimeInterval: 0.01)
observed = try readRate(device)
}
guard observed == requested else {
throw RateError(description: "Rate readback timed out: previous=\(previous) requested=\(requested) observed=\(observed)")
}
print("previous=\(previous) new=\(observed) device=\(device)")
} catch {
fputs("\(error)\n", stderr)
exit(1)
}