Summary
AudioProcessor has data races on audioEnergy and audioSamples — they are written from the audio tap callback thread and read from arbitrary threads (e.g., main thread) without any synchronization.
Details
In AudioProcessor.swift, processBuffer() is called from the audio engine tap's callback thread:
// ~line 907-917, called from audio tap callback thread
func processBuffer(_ buffer: [Float]) {
audioSamples.append(contentsOf: buffer) // write
// ...
self.audioEnergy.append(newEnergy) // write
}
Meanwhile, relativeEnergy reads these from any thread:
// ~line 210, read from any thread
public var relativeEnergy: [Float] {
return self.audioEnergy.map { $0.rel }
}
And audioSamples is also read externally:
public var audioSamples: ContiguousArray<Float> = [] // no synchronization
This is a Swift Concurrency data race — concurrent read/write on non-isolated, non-sendable mutable state.
Impact
- When polling
relativeEnergy from the main thread (e.g., for VAD-based end-of-speech detection), values can be stale, skipped, or inconsistent.
- In our case, this caused
AudioProcessor.isVoiceDetected() to not work reliably when called from a @MainActor-isolated context while recording.
- Could potentially crash in optimized builds due to unsynchronized array mutations.
Reproduction
- Start recording with
audioProcessor.startRecordingLive()
- Poll
audioProcessor.relativeEnergy from the main thread in a timer
- Observe that values are inconsistent or empty
Suggested Fix
Either:
- Make
AudioProcessor an actor
- Or protect
audioEnergy / audioSamples with a lock (e.g., NSLock or os_unfair_lock)
Environment
- WhisperKit 0.17.0
- Xcode 26.3
- iOS 26.3 / macOS 15
Summary
AudioProcessorhas data races onaudioEnergyandaudioSamples— they are written from the audio tap callback thread and read from arbitrary threads (e.g., main thread) without any synchronization.Details
In
AudioProcessor.swift,processBuffer()is called from the audio engine tap's callback thread:Meanwhile,
relativeEnergyreads these from any thread:And
audioSamplesis also read externally:This is a Swift Concurrency data race — concurrent read/write on non-isolated, non-sendable mutable state.
Impact
relativeEnergyfrom the main thread (e.g., for VAD-based end-of-speech detection), values can be stale, skipped, or inconsistent.AudioProcessor.isVoiceDetected()to not work reliably when called from a@MainActor-isolated context while recording.Reproduction
audioProcessor.startRecordingLive()audioProcessor.relativeEnergyfrom the main thread in a timerSuggested Fix
Either:
AudioProcessoranactoraudioEnergy/audioSampleswith a lock (e.g.,NSLockoros_unfair_lock)Environment