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
64 changes: 59 additions & 5 deletions Sources/AudioCommon/StreamingAudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)?

Expand Down Expand Up @@ -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..<fadeFrames {
output[i] *= Float(i) / Float(fadeFrames)
}
output = Self.startupDeclick(
samples,
sampleRate: fmt.sampleRate,
prerollDuration: startupPrerollDuration,
fadeInDuration: startupFadeInDuration)
}
}

Expand All @@ -229,6 +246,30 @@ public final class StreamingAudioPlayer: @unchecked Sendable {
lock.unlock()
}

static func startupDeclick(
_ samples: [Float],
sampleRate: Double,
prerollDuration: Double = 0,
fadeInDuration: Double = 0.005
) -> [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..<fadeFrames {
let x = Float(i) / Float(max(fadeFrames - 1, 1))
let gain = x * x * (3 - 2 * x)
conditioned[i] *= gain
}
let prerollFrames = max(0, Int((sampleRate * max(0, prerollDuration)).rounded(.up)))
guard prerollFrames > 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 }
Expand Down Expand Up @@ -361,6 +402,7 @@ public final class StreamingAudioPlayer: @unchecked Sendable {
isFirstChunk = true
totalWritten = 0
totalRead = 0
underflowEvents = 0
ringBuffer?.reset()
lock.unlock()
}
Expand All @@ -380,6 +422,7 @@ public final class StreamingAudioPlayer: @unchecked Sendable {
isFirstChunk = true
totalWritten = 0
totalRead = 0
underflowEvents = 0
ringBuffer?.reset()
lock.unlock()
isPlaying = false
Expand All @@ -404,6 +447,7 @@ public final class StreamingAudioPlayer: @unchecked Sendable {
isFirstChunk = true
totalWritten = 0
totalRead = 0
underflowEvents = 0
ringBuffer?.reset()
lock.unlock()
isPlaying = false
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions Tests/AudioCommonTests/StreamingAudioPlayerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
Expand Down