diff --git a/Examples/CLIPlayer/Package.resolved b/Examples/CLIPlayer/Package.resolved index d396276..b993940 100644 --- a/Examples/CLIPlayer/Package.resolved +++ b/Examples/CLIPlayer/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ae9debf913fe343bc1666f96bbfff92a2bc75d092ebfe274da35849a3886bb29", + "originHash" : "7f004e306b3114688e11b6ff9728b35b13f3654fa9c306691fe145020ab90ab1", "pins" : [ { "identity" : "flac-binary-xcframework", @@ -18,6 +18,15 @@ "revision" : "c0e822e18738ad913864e98d9614927ac1e9337c", "version" : "0.1.2" } + }, + { + "identity" : "swift-sodium", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jedisct1/swift-sodium.git", + "state" : { + "revision" : "4f9164a0a2c9a6a7ff53a2833d54a5c79c957342", + "version" : "0.9.1" + } } ], "version" : 3 diff --git a/Examples/CLIPlayer/Package.swift b/Examples/CLIPlayer/Package.swift index 86e3565..8890d5b 100644 --- a/Examples/CLIPlayer/Package.swift +++ b/Examples/CLIPlayer/Package.swift @@ -7,7 +7,7 @@ let package = Package( .macOS(.v14) ], dependencies: [ - .package(path: "../..") + .package(name: "SendspinKit", path: "../..") ], targets: [ .executableTarget( diff --git a/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift b/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift index 389a7ab..cf6dba2 100644 --- a/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift +++ b/Examples/CLIPlayer/Sources/CLIPlayer/CLIPlayer.swift @@ -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 ) } @@ -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 @@ -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 @@ -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 { @@ -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 diff --git a/Examples/CLIPlayer/Sources/CLIPlayer/main.swift b/Examples/CLIPlayer/Sources/CLIPlayer/main.swift index af44358..56999c3 100644 --- a/Examples/CLIPlayer/Sources/CLIPlayer/main.swift +++ b/Examples/CLIPlayer/Sources/CLIPlayer/main.swift @@ -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 @@ -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 @@ -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 { @@ -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)") diff --git a/Examples/ClockSyncDiagnostics/Package.swift b/Examples/ClockSyncDiagnostics/Package.swift index 6b91d7c..c9395f2 100644 --- a/Examples/ClockSyncDiagnostics/Package.swift +++ b/Examples/ClockSyncDiagnostics/Package.swift @@ -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: [ diff --git a/Examples/ControllerClient/Package.swift b/Examples/ControllerClient/Package.swift index 61b10f8..713c811 100644 --- a/Examples/ControllerClient/Package.swift +++ b/Examples/ControllerClient/Package.swift @@ -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: [ diff --git a/Examples/DiscoveryExample/Package.swift b/Examples/DiscoveryExample/Package.swift index 788d88d..8ff66dd 100644 --- a/Examples/DiscoveryExample/Package.swift +++ b/Examples/DiscoveryExample/Package.swift @@ -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: [ diff --git a/Examples/ErrorRecovery/Package.swift b/Examples/ErrorRecovery/Package.swift index 06e7b32..5348aa4 100644 --- a/Examples/ErrorRecovery/Package.swift +++ b/Examples/ErrorRecovery/Package.swift @@ -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: [ diff --git a/Examples/MetadataClient/Package.swift b/Examples/MetadataClient/Package.swift index 816dba4..4651d78 100644 --- a/Examples/MetadataClient/Package.swift +++ b/Examples/MetadataClient/Package.swift @@ -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: [ diff --git a/Examples/MultiCodecPlayer/Package.swift b/Examples/MultiCodecPlayer/Package.swift index 56fb778..98b6160 100644 --- a/Examples/MultiCodecPlayer/Package.swift +++ b/Examples/MultiCodecPlayer/Package.swift @@ -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: [ diff --git a/Examples/VisualizerClient/Package.swift b/Examples/VisualizerClient/Package.swift index a1416ac..16d67f0 100644 --- a/Examples/VisualizerClient/Package.swift +++ b/Examples/VisualizerClient/Package.swift @@ -5,7 +5,7 @@ let package = Package( name: "VisualizerClient", platforms: [.macOS(.v14)], dependencies: [ - .package(path: "../..") + .package(name: "SendspinKit", path: "../..") ], targets: [ .target( diff --git a/Sources/SendspinKit/Audio/AudioEngine.swift b/Sources/SendspinKit/Audio/AudioEngine.swift index a95d67c..3a97731 100644 --- a/Sources/SendspinKit/Audio/AudioEngine.swift +++ b/Sources/SendspinKit/Audio/AudioEngine.swift @@ -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 @@ -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 { @@ -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 @@ -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 diff --git a/Sources/SendspinKit/Audio/AudioPlayer.swift b/Sources/SendspinKit/Audio/AudioPlayer.swift index 082f10e..0d86863 100644 --- a/Sources/SendspinKit/Audio/AudioPlayer.swift +++ b/Sources/SendspinKit/Audio/AudioPlayer.swift @@ -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), diff --git a/scripts/set-output-rate.swift b/scripts/set-output-rate.swift new file mode 100644 index 0000000..a6656cc --- /dev/null +++ b/scripts/set-output-rate.swift @@ -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.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 ") + } + var defaultOutput = address(kAudioHardwarePropertyDefaultOutputDevice) + var device = AudioDeviceID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.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.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.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) +}