diff --git a/Sources/AudioCommon/StreamingAudioPlayer.swift b/Sources/AudioCommon/StreamingAudioPlayer.swift index 5be907eb..6e8d72d2 100644 --- a/Sources/AudioCommon/StreamingAudioPlayer.swift +++ b/Sources/AudioCommon/StreamingAudioPlayer.swift @@ -110,6 +110,14 @@ public final class StreamingAudioPlayer: @unchecked Sendable { /// Number of samples written for external diagnostics. public var totalWrittenSamples: Int { totalWritten } private var totalRead: Int = 0 + private var underflowEvents: Int = 0 + + /// Number of render callbacks that had to output silence while playback was active. + public var underflowCount: Int { + lock.lock() + defer { lock.unlock() } + return underflowEvents + } public private(set) var isPlaying = false private var playbackFinishedFired = false @@ -118,6 +126,14 @@ public final class StreamingAudioPlayer: @unchecked Sendable { /// Default 1.0s — sufficient for streaming TTS at RTF < 0.6. public var preBufferDuration: Double = 1.0 + /// Optional silence prepended to the first audible chunk so CoreAudio starts from a stable zero signal. + /// Default 0 preserves existing playback latency for models that already start cleanly. + public var startupPrerollDuration: Double = 0 + + /// Fade-in applied to the first audible chunk to avoid a discontinuity from silence to waveform. + /// Default 5 ms preserves the previous first-chunk fade behavior. + public var startupFadeInDuration: Double = 0.005 + /// Callback when all audio has finished playing. public var onPlaybackFinished: (() -> Void)? @@ -205,12 +221,13 @@ public final class StreamingAudioPlayer: @unchecked Sendable { let rms = sqrt(sumSq / Float(samples.count)) if rms < 0.005 { return } // Only drop near-silence (codec init noise) isFirstChunk = false - // 5ms fade-in to prevent pop + // Start from explicit silence and fade in the first real waveform to prevent pops/clicks. if let fmt = format { - let fadeFrames = min(samples.count, Int(fmt.sampleRate * 0.005)) - for i in 0.. [Float] { + guard !samples.isEmpty, sampleRate > 0 else { return samples } + var conditioned = samples + let fadeFrames = min( + conditioned.count, + max(2, Int((sampleRate * max(0, fadeInDuration)).rounded(.up)))) + for i in 0.. 0 else { return conditioned } + var output = [Float](repeating: 0, count: prerollFrames) + output.reserveCapacity(prerollFrames + conditioned.count) + output.append(contentsOf: conditioned) + return output + } + /// Write samples with resampling from sourceSampleRate to the player's rate. public func play(samples: [Float], sampleRate: Int) throws { guard let fmt = format else { return } @@ -361,6 +402,7 @@ public final class StreamingAudioPlayer: @unchecked Sendable { isFirstChunk = true totalWritten = 0 totalRead = 0 + underflowEvents = 0 ringBuffer?.reset() lock.unlock() } @@ -380,6 +422,7 @@ public final class StreamingAudioPlayer: @unchecked Sendable { isFirstChunk = true totalWritten = 0 totalRead = 0 + underflowEvents = 0 ringBuffer?.reset() lock.unlock() isPlaying = false @@ -404,6 +447,7 @@ public final class StreamingAudioPlayer: @unchecked Sendable { isFirstChunk = true totalWritten = 0 totalRead = 0 + underflowEvents = 0 ringBuffer?.reset() lock.unlock() isPlaying = false @@ -443,6 +487,11 @@ public final class StreamingAudioPlayer: @unchecked Sendable { // Zero-fill remainder if not enough if read < frames { dst.advanced(by: read).update(repeating: 0, count: frames - read) + if !complete { + self.lock.lock() + self.underflowEvents += 1 + self.lock.unlock() + } } self.lock.lock() self.totalRead += read @@ -458,6 +507,11 @@ public final class StreamingAudioPlayer: @unchecked Sendable { } else { // Underflow — output silence, keep waiting for more data dst.update(repeating: 0, count: frames) + if !complete { + self.lock.lock() + self.underflowEvents += 1 + self.lock.unlock() + } } return noErr diff --git a/Tests/AudioCommonTests/StreamingAudioPlayerTests.swift b/Tests/AudioCommonTests/StreamingAudioPlayerTests.swift index 9b71c2b4..638f1ab8 100644 --- a/Tests/AudioCommonTests/StreamingAudioPlayerTests.swift +++ b/Tests/AudioCommonTests/StreamingAudioPlayerTests.swift @@ -85,6 +85,44 @@ final class StreamingAudioPlayerTests: XCTestCase { // Without engine, can't truly test — but should not crash } + func testStartupDeclickDefaultDoesNotAddLatency() { + let input = [Float](repeating: 1, count: 100) + let output = StreamingAudioPlayer.startupDeclick(input, sampleRate: 1000) + + XCTAssertEqual(output.count, input.count) + XCTAssertEqual(output.first ?? -1, 0, accuracy: 0.0001) + XCTAssertEqual(output[4], 1, accuracy: 0.0001) + XCTAssertEqual(output.last ?? 0, 1, accuracy: 0.0001) + } + + func testStartupDeclickPrependsSilenceAndFadesFirstChunk() { + let input = [Float](repeating: 1, count: 100) + let output = StreamingAudioPlayer.startupDeclick( + input, + sampleRate: 1000, + prerollDuration: 0.010, + fadeInDuration: 0.020) + + XCTAssertEqual(output.count, 110) + XCTAssertTrue(output.prefix(10).allSatisfy { $0 == 0 }) + XCTAssertEqual(output[10], 0, accuracy: 0.0001) + XCTAssertGreaterThan(output[29], 0.99) + XCTAssertEqual(output.last ?? 0, 1, accuracy: 0.0001) + } + + func testStartupDeclickCanDisablePreroll() { + let input = [Float](repeating: 1, count: 10) + let output = StreamingAudioPlayer.startupDeclick( + input, + sampleRate: 1000, + prerollDuration: 0, + fadeInDuration: 0.004) + + XCTAssertEqual(output.count, input.count) + XCTAssertEqual(output.first ?? -1, 0, accuracy: 0.0001) + XCTAssertEqual(output.last ?? 0, 1, accuracy: 0.0001) + } + func testEmptyChunkIgnored() { let player = StreamingAudioPlayer() player.scheduleChunk([])