fix(track-b 4): wire AudioGraphOwner + LiveSpeechAnalyzerBridge SpeechAnalyzer API
Track B-4 of the 2026-05-03 voice audit fix plan (.planning/audit-2026-05-03/voice.md). Two production paths were dead: - installVoice constructed WakeWordDAG but never built the AudioGraphOwner that owns the AVAudioEngine + ring buffer it feeds from. Mic taps were installed inside AudioGraph.init, but no AudioGraph was ever created in production, so the wake-word detached feed Task spun on an empty ring for the entire process lifetime. - LiveSpeechAnalyzerBridge.feed(_:) was `_ = chunk` — a structural shell left over from Plan 06-03 that was meant to be wired in 06-05 but never was. Changes: * AudioGraphOwner: add `setCancelInFlight` / `setReleaseORTSessions` cross-actor setters so MainActor install code can install the cancel-in-flight closure under Swift 6 mode. * PCMBufferBuilder: new pure-AVFoundation translator extracted from the bridge (`AudioChunk.pcm16k` → `AVAudioPCMBuffer` + sample-rate convert via `AVAudioConverter`). Lives outside the macOS-26-gated bridge so unit tests run on every supported macOS. * LiveSpeechAnalyzerBridge: replace the no-op feed with real wiring — `start()` builds `SpeechTranscriber` + `SpeechAnalyzer`, queries `SpeechAnalyzer.bestAvailableAudioFormat`, opens the `AsyncStream<AnalyzerInput>` input pipe, and spawns a result-drain Task. `feed()` packs the chunk into a PCM buffer (with optional format convert) and yields `AnalyzerInput(buffer:)`. `finish()` finalises through end-of-input and awaits the drain. Eager partials stream in init removes the ordering race against the SpeechAnalyzerSTT feed/drain Task pair. * AppDelegate: add strong properties `audioGraphOwner`, `audioGraphDegradationTask`, `audioGraphRebuildTask`. installVoice now constructs AudioGraphOwner with degradation/rebuild stream continuations, spawns degradation + rebuild consumers BEFORE `open()` (so the retry-with-aec=false path's `.aecUnavailable` is observed), opens the graph fail-soft, hands the ring buffer to `WakeWordDAG.start(ring:)`, and sets `cancelInFlight` to cancel the DAG before teardown. Failure to open is non-fatal: voice degrades to dormant + banner, app keeps running. * VoiceWiringTests testW1: assert the three new strong properties exist and start nil. * PCMBufferBuilderTests (new, +7): frame count, payload fidelity, empty input, same-format passthrough, 16k→24k upsample ratio, empty-input convert. Verification: * swift test packages/Voice — 77 tests, +7 PCMBufferBuilderTests, 3 skipped, 1 pre-existing failure (TTSInterruptTests.testI3, the 10s timeout flake unrelated to this work; deterministic before and after). * `bash scripts/check-app-builds.sh` — App target Debug build clean. * Boundary gates: install-order, no-modal-presentation, no-null-voice-adapters, orchestrator-events-single-consumer, single-writer-hudstate — all PASS. What's still TODO (Track B-5): * End-to-end voice loop test: feed a known WAV at the audio graph, assert STT result lands on the orchestrator. The pipe is now wired but no integration test exercises it yet. * VAD-gated chunk feeding from VoiceController to STT — the bridge accepts chunks now but VoiceController.startSTTSession() has no producer feeding `sttChunkCont`. Tracked in audit PLAN 06-03 anti-pattern callout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
52d0c9480e
commit
e9c0a34298
6 files changed
+450
-25
No files matched your search
@@ -170,6 +170,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// Menu-bar wake-word mute toggle (Plan 06-05 / VOICE-12).
|
||||
var muteWakeWord: MuteWakeWord?
|
||||
|
||||
/// Owns the live `AVAudioEngine` audio graph (Plan 06-01) — opened in
|
||||
/// `installVoice()` so mic samples flow into the wake-word DAG. Held
|
||||
/// strongly because the actor's tap closures retain the ring buffer.
|
||||
/// Track B-4 (2026-05-03 voice audit fix): production was never
|
||||
/// constructing this, so wake-word inference ran against an empty ring.
|
||||
var audioGraphOwner: AudioGraphOwner?
|
||||
|
||||
/// Drains `AudioGraphOwner.degradationStream` into
|
||||
/// `voiceController.handleAECUnavailable()` so VOICE-09 fallback banners
|
||||
/// surface natively. Cancelled on shutdown.
|
||||
var audioGraphDegradationTask: Task<Void, Never>?
|
||||
|
||||
/// Drains `AudioGraphOwner.rebuildStream` to dismiss the AEC banner
|
||||
/// once an aec=on rebuild succeeds.
|
||||
var audioGraphRebuildTask: Task<Void, Never>?
|
||||
|
||||
/// Task spawned in `applicationWillFinishLaunching` that constructs and
|
||||
/// starts the voice subsystem. Held strongly so the async setup isn't
|
||||
/// cancelled prematurely.
|
||||
@@ -585,6 +601,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
orchToReplayDrainTask?.cancel()
|
||||
// Plan 06-05: shut down voice subsystem.
|
||||
voiceInstallTask?.cancel()
|
||||
// Track B-4: degradation/rebuild stream consumers + audio graph owner.
|
||||
audioGraphDegradationTask?.cancel()
|
||||
audioGraphRebuildTask?.cancel()
|
||||
if let owner = audioGraphOwner {
|
||||
Task { await owner.shutdown() }
|
||||
}
|
||||
if let vc = voiceController {
|
||||
Task { await vc.shutdown() }
|
||||
}
|
||||
@@ -677,6 +699,82 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Construct WakeWordDAG.
|
||||
let wakeWordDAG = WakeWordDAG(session: wakeWordSession)
|
||||
|
||||
// Track B-4 (2026-05-03 voice audit fix): construct AudioGraphOwner,
|
||||
// open it, and feed its RingBuffer into the wake-word DAG. Without
|
||||
// this, wake-word inference loops on a ring that production never
|
||||
// wrote to (mic taps were never installed). Audit:
|
||||
// `.planning/audit-2026-05-03/voice.md`.
|
||||
//
|
||||
// Order matters: the degradation/rebuild consumer Tasks must be
|
||||
// spawned BEFORE `open()` because `open()` may immediately yield
|
||||
// `.aecUnavailable` on its retry-with-aec=false path (VOICE-09).
|
||||
let (degradationStream, degradationCont) = AsyncStream<DegradationReason>.makeStream()
|
||||
let (rebuildStream, rebuildCont) = AsyncStream<RebuildEvent>.makeStream()
|
||||
|
||||
let graphOwner = AudioGraphOwner(
|
||||
degradationContinuation: degradationCont,
|
||||
rebuildContinuation: rebuildCont
|
||||
)
|
||||
self.audioGraphOwner = graphOwner
|
||||
|
||||
// Spawn degradation consumer — fires `.aecUnavailable` into the
|
||||
// VoiceController's banner path. VoiceController may not exist yet
|
||||
// when the first event arrives (open() runs below), so the consumer
|
||||
// resolves the controller lazily on each event.
|
||||
audioGraphDegradationTask = Task { @MainActor [weak self] in
|
||||
for await reason in degradationStream {
|
||||
guard case .aecUnavailable = reason else { continue }
|
||||
if let vc = self?.voiceController {
|
||||
await vc.handleAECUnavailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn rebuild consumer — once an aec=on rebuild succeeds we ask the
|
||||
// controller to dismiss the banner. The owner only emits
|
||||
// `.reconfiguring(reason:)` on each rebuild start; the variant flip
|
||||
// is observable via `await graphOwner.currentVariant`. We poll the
|
||||
// variant after each rebuild event.
|
||||
audioGraphRebuildTask = Task { @MainActor [weak self] in
|
||||
for await _ in rebuildStream {
|
||||
guard let self else { return }
|
||||
let variant = await self.audioGraphOwner?.currentVariant
|
||||
if case .aecOn = variant {
|
||||
await self.voiceController?.handleAECRestored()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the graph. Failure here is non-fatal: voice degrades to
|
||||
// dormant + banner, the rest of the app keeps running. The owner
|
||||
// itself emits `.aecUnavailable` on its retry path, so we only need
|
||||
// to log on the catastrophic `bothVariantsFailed` case.
|
||||
do {
|
||||
try await graphOwner.open()
|
||||
} catch {
|
||||
systemLogger?.warning("installVoice: AudioGraphOwner.open() failed (\(String(describing: error))) — voice subsystem dormant")
|
||||
audioGraphDegradationTask?.cancel()
|
||||
audioGraphRebuildTask?.cancel()
|
||||
self.audioGraphOwner = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Hand the ring buffer to the wake-word DAG so mic samples flow
|
||||
// through the openWakeWord pipeline. The detached feed Task inside
|
||||
// the DAG drives wakeWordStream, which VoiceController consumes.
|
||||
if let ring = await graphOwner.ringBuffer {
|
||||
await wakeWordDAG.start(ring: ring)
|
||||
} else {
|
||||
systemLogger?.warning("installVoice: graph opened but ringBuffer is nil — wake-word DAG not started")
|
||||
}
|
||||
|
||||
// Wire teardown step 1 (VOICE-10): cancel wake-word inference before
|
||||
// the engine stops. Without this, the detached feed Task races the
|
||||
// ring deallocation during graph rebuilds.
|
||||
await graphOwner.setCancelInFlight { [wakeWordDAG] in
|
||||
await wakeWordDAG.cancel()
|
||||
}
|
||||
|
||||
// Construct VoiceController. Plan 09-04 (D-09 + D-11) replaces the
|
||||
// three Null placeholder adapters with the production adapter triad:
|
||||
//
|
||||
|
||||
@@ -72,6 +72,14 @@ final class VoiceWiringTests: XCTestCase {
|
||||
"W1: muteWakeWord should be nil before install")
|
||||
XCTAssertNil(delegate.voiceInstallTask,
|
||||
"W1: voiceInstallTask should be nil before launch")
|
||||
// Track B-4: AudioGraphOwner + degradation/rebuild consumer tasks
|
||||
// must exist as strong properties so they outlive installVoice().
|
||||
XCTAssertNil(delegate.audioGraphOwner,
|
||||
"W1/B-4: audioGraphOwner should be nil before install")
|
||||
XCTAssertNil(delegate.audioGraphDegradationTask,
|
||||
"W1/B-4: audioGraphDegradationTask should be nil before install")
|
||||
XCTAssertNil(delegate.audioGraphRebuildTask,
|
||||
"W1/B-4: audioGraphRebuildTask should be nil before install")
|
||||
}
|
||||
|
||||
// MARK: - W2: dormantVoiceContinuation is alive after installBus (before voice install)
|
||||
|
||||
@@ -125,6 +125,19 @@ public actor AudioGraphOwner {
|
||||
/// `(@Sendable () async -> Void)?` — must be `nil`-safe.
|
||||
public var releaseORTSessions: (@Sendable () async -> Void)?
|
||||
|
||||
/// Cross-actor setter for `cancelInFlight`. Plan 06-05 wires this from
|
||||
/// `@MainActor` install code so it can't reach the actor-isolated `var`
|
||||
/// directly under Swift 6 mode.
|
||||
public func setCancelInFlight(_ closure: @escaping @Sendable () async -> Void) {
|
||||
self.cancelInFlight = closure
|
||||
}
|
||||
|
||||
/// Cross-actor setter for `releaseORTSessions`. Same rationale as
|
||||
/// `setCancelInFlight`.
|
||||
public func setReleaseORTSessions(_ closure: @escaping @Sendable () async -> Void) {
|
||||
self.releaseORTSessions = closure
|
||||
}
|
||||
|
||||
// MARK: - Internal test seams
|
||||
|
||||
/// Test seam for mic-re-grant watcher. In tests, inject a closure that
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
// MARK: - PCMBufferBuilder
|
||||
|
||||
/// Pure-AVFoundation helpers used by `LiveSpeechAnalyzerBridge` to translate
|
||||
/// `AudioChunk` (16 kHz mono Float32 `[Float]`) into `AVAudioPCMBuffer` and
|
||||
/// optionally up/down-sample to the analyzer's preferred format.
|
||||
///
|
||||
/// Lives outside `LiveSpeechAnalyzerBridge` because that bridge is gated on
|
||||
/// `@available(macOS 26.0, *)` (SpeechAnalyzer is Tahoe-only) but these
|
||||
/// buffer helpers are pure AVFoundation and runnable on every supported OS.
|
||||
/// Track B-4 (2026-05-03 voice audit fix) tests target this type directly.
|
||||
public enum PCMBufferBuilder {
|
||||
|
||||
/// Canonical input format for AudioChunks: 16 kHz mono Float32, non-interleaved.
|
||||
/// Anchored to `STTProvider`'s contract (`AudioChunk.pcm16k`).
|
||||
public static let audioChunkFormat: AVAudioFormat = {
|
||||
guard let fmt = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
) else {
|
||||
preconditionFailure("PCMBufferBuilder.audioChunkFormat: 16k mono Float32 should always construct")
|
||||
}
|
||||
return fmt
|
||||
}()
|
||||
|
||||
/// Wraps a `[Float]` of 16 kHz mono Float32 PCM into an `AVAudioPCMBuffer`
|
||||
/// with `frameLength = samples.count`.
|
||||
///
|
||||
/// Returns `nil` if the buffer can't be allocated (out-of-memory; treated
|
||||
/// as transient — caller should drop the chunk and continue feeding).
|
||||
public static func makePCMBuffer(from samples: [Float]) -> AVAudioPCMBuffer? {
|
||||
let format = audioChunkFormat
|
||||
let capacity = AVAudioFrameCount(max(samples.count, 1))
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else {
|
||||
return nil
|
||||
}
|
||||
buffer.frameLength = AVAudioFrameCount(samples.count)
|
||||
guard samples.isEmpty == false, let channel = buffer.floatChannelData?[0] else {
|
||||
return buffer
|
||||
}
|
||||
samples.withUnsafeBufferPointer { src in
|
||||
channel.update(from: src.baseAddress!, count: samples.count)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
/// Converts `src` (in `audioChunkFormat`) into a buffer in `dst` format,
|
||||
/// using a pre-built `AVAudioConverter`. Returns `nil` on conversion error.
|
||||
///
|
||||
/// The output buffer's capacity is sized by the sample-rate ratio + a small
|
||||
/// safety margin. Empty inputs return an empty output buffer (no conversion).
|
||||
public static func convert(
|
||||
buffer src: AVAudioPCMBuffer,
|
||||
using converter: AVAudioConverter,
|
||||
to dst: AVAudioFormat
|
||||
) -> AVAudioPCMBuffer? {
|
||||
if src.frameLength == 0 {
|
||||
return AVAudioPCMBuffer(pcmFormat: dst, frameCapacity: 1)
|
||||
}
|
||||
let ratio = dst.sampleRate / src.format.sampleRate
|
||||
let outCapacity = AVAudioFrameCount((Double(src.frameLength) * ratio).rounded(.up)) + 64
|
||||
guard let out = AVAudioPCMBuffer(pcmFormat: dst, frameCapacity: outCapacity) else {
|
||||
return nil
|
||||
}
|
||||
var error: NSError?
|
||||
var consumed = false
|
||||
let status = converter.convert(to: out, error: &error) { _, outStatus in
|
||||
if consumed {
|
||||
outStatus.pointee = .endOfStream
|
||||
return nil
|
||||
}
|
||||
consumed = true
|
||||
outStatus.pointee = .haveData
|
||||
return src
|
||||
}
|
||||
if status == .error {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
// MARK: - SpeechAnalyzerBridge (test seam)
|
||||
|
||||
@@ -145,49 +147,167 @@ public final class SpeechAnalyzerSTT: STTProvider {
|
||||
///
|
||||
/// The `SpeechAnalyzer` / `SpeechTranscriber` APIs are new in macOS 26 Tahoe.
|
||||
/// On earlier systems, `SpeechAnalyzerSTT` falls back to `UnavailableSpeechAnalyzerBridge`.
|
||||
///
|
||||
/// ## Track B-4 (2026-05-03 voice audit fix)
|
||||
/// `feed()` was a `_ = chunk` no-op, leaving STT deaf in production. Real wiring
|
||||
/// now lives here:
|
||||
///
|
||||
/// 1. `start()` builds the transcriber + analyzer, queries
|
||||
/// `SpeechAnalyzer.bestAvailableAudioFormat`, opens the
|
||||
/// `AsyncStream<AnalyzerInput>` input pipe, and spawns a background task
|
||||
/// draining `transcriber.results` into the partials continuation.
|
||||
/// 2. `feed()` packs `AudioChunk.pcm16k` into an `AVAudioPCMBuffer`
|
||||
/// (`PCMBufferBuilder.makePCMBuffer`) and converts to the analyzer's
|
||||
/// preferred format if needed, then yields an `AnalyzerInput(buffer:)`
|
||||
/// on the input builder.
|
||||
/// 3. `finish()` finishes the input stream, calls
|
||||
/// `analyzer.finalizeAndFinishThroughEndOfInput()`, and awaits the result task.
|
||||
/// 4. `partialResults()` returns the pre-built partial-text stream so
|
||||
/// `SpeechAnalyzerSTT.transcribe` can subscribe before audio arrives.
|
||||
/// 5. `finalText()` waits for the result drain to complete and returns the
|
||||
/// concatenation of every `isFinal` segment.
|
||||
///
|
||||
/// `nonisolated(unsafe)` storage is deliberate: the surrounding
|
||||
/// `SpeechAnalyzerSTT` serialises `start → feed* → finish → finalText` through
|
||||
/// its single `feedTask` chain (see line 73), so cross-task races on these
|
||||
/// fields cannot occur in production. Tests that exercise the bridge directly
|
||||
/// must follow the same sequence.
|
||||
@available(macOS 26.0, *)
|
||||
private final class LiveSpeechAnalyzerBridge: SpeechAnalyzerBridge {
|
||||
|
||||
// NOTE: The `Speech` framework `SpeechAnalyzer` + `SpeechTranscriber` APIs are
|
||||
// new in macOS 26 Tahoe. Since the SDK may not expose them yet under macOS 14
|
||||
// (the deployment target set in Package.swift), we use runtime checks.
|
||||
//
|
||||
// The actual API surface is documented in PLAN 06-03 <interfaces>:
|
||||
// SpeechAnalyzer.init(...)
|
||||
// SpeechAnalyzer.add(modules:) async throws
|
||||
// SpeechAnalyzer.feed(_ buffer: AVAudioPCMBuffer) async throws
|
||||
// SpeechAnalyzer.finish() async throws
|
||||
// SpeechTranscriber.results: AsyncStream<SpeechTranscriptionResult>
|
||||
//
|
||||
// Plan 06-05 (VoiceController) wires the live bridge with AVAudioPCMBuffer conversion.
|
||||
// For Plan 06-03, this is a structural shell; the real wiring happens in 06-05.
|
||||
// MARK: - Storage (single-writer-per-phase; SpeechAnalyzerSTT serialises)
|
||||
|
||||
nonisolated(unsafe) private var partialsContinuation: AsyncStream<String>.Continuation?
|
||||
nonisolated(unsafe) private var collectedText = ""
|
||||
nonisolated(unsafe) private var finalizationError: (any Error)?
|
||||
nonisolated(unsafe) private var transcriber: SpeechTranscriber?
|
||||
nonisolated(unsafe) private var analyzer: SpeechAnalyzer?
|
||||
nonisolated(unsafe) private var inputBuilder: AsyncStream<AnalyzerInput>.Continuation?
|
||||
nonisolated(unsafe) private var analyzerFormat: AVAudioFormat?
|
||||
nonisolated(unsafe) private var converter: AVAudioConverter?
|
||||
|
||||
private let partialsStream: AsyncStream<String>
|
||||
private let partialsContinuation: AsyncStream<String>.Continuation
|
||||
nonisolated(unsafe) private var resultsTask: Task<Void, Never>?
|
||||
nonisolated(unsafe) private var collectedText: String = ""
|
||||
nonisolated(unsafe) private var resultsError: (any Error)?
|
||||
nonisolated(unsafe) private var didFinish: Bool = false
|
||||
|
||||
init() {
|
||||
let (stream, cont) = AsyncStream<String>.makeStream()
|
||||
self.partialsStream = stream
|
||||
self.partialsContinuation = cont
|
||||
}
|
||||
|
||||
// MARK: - SpeechAnalyzerBridge
|
||||
|
||||
func start() async throws {
|
||||
// Setup happens on first feed(); placeholder for 06-05 to wire live APIs.
|
||||
// Idempotent: if already started, do nothing. SpeechAnalyzerSTT calls
|
||||
// start exactly once per session, but the guard keeps us safe.
|
||||
guard analyzer == nil else { return }
|
||||
|
||||
let transcriber = SpeechTranscriber(
|
||||
locale: Locale.current,
|
||||
transcriptionOptions: [],
|
||||
reportingOptions: [.volatileResults],
|
||||
attributeOptions: []
|
||||
)
|
||||
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
||||
|
||||
// bestAvailableAudioFormat returns nil if no module has a format
|
||||
// preference (rare). Fall back to the AudioChunk format so we still
|
||||
// attempt feeding rather than dropping silently.
|
||||
let format = await SpeechAnalyzer.bestAvailableAudioFormat(
|
||||
compatibleWith: [transcriber]
|
||||
) ?? PCMBufferBuilder.audioChunkFormat
|
||||
|
||||
// Build the converter only when formats differ. AVAudioConverter
|
||||
// construction is cheap but has small per-frame overhead, so skip
|
||||
// when possible.
|
||||
let conv: AVAudioConverter?
|
||||
if !format.isEqual(PCMBufferBuilder.audioChunkFormat) {
|
||||
conv = AVAudioConverter(from: PCMBufferBuilder.audioChunkFormat, to: format)
|
||||
} else {
|
||||
conv = nil
|
||||
}
|
||||
|
||||
let (inputSequence, builder) = AsyncStream<AnalyzerInput>.makeStream()
|
||||
|
||||
self.transcriber = transcriber
|
||||
self.analyzer = analyzer
|
||||
self.analyzerFormat = format
|
||||
self.converter = conv
|
||||
self.inputBuilder = builder
|
||||
|
||||
// Spawn the result drain BEFORE start() so we don't miss early
|
||||
// partials. AttributedString is bridged via `result.text.characters`.
|
||||
let cont = self.partialsContinuation
|
||||
resultsTask = Task { [weak self] in
|
||||
do {
|
||||
for try await result in transcriber.results {
|
||||
let text = String(result.text.characters)
|
||||
if result.isFinal {
|
||||
self?.collectedText.append(text)
|
||||
}
|
||||
cont.yield(text)
|
||||
}
|
||||
} catch {
|
||||
self?.resultsError = error
|
||||
}
|
||||
cont.finish()
|
||||
}
|
||||
|
||||
try await analyzer.start(inputSequence: inputSequence)
|
||||
}
|
||||
|
||||
func feed(_ chunk: AudioChunk) async throws {
|
||||
// Convert AudioChunk → AVAudioPCMBuffer and call analyzer.feed()
|
||||
// Wired fully in Plan 06-05. Placeholder here.
|
||||
_ = chunk
|
||||
guard let inputBuilder, !chunk.pcm16k.isEmpty else { return }
|
||||
|
||||
guard let raw = PCMBufferBuilder.makePCMBuffer(from: chunk.pcm16k) else {
|
||||
// Allocation failed — drop this chunk; SpeechAnalyzer can't recover from
|
||||
// missing samples but the next chunk may succeed.
|
||||
return
|
||||
}
|
||||
|
||||
let toFeed: AVAudioPCMBuffer
|
||||
if let converter, let analyzerFormat {
|
||||
guard let converted = PCMBufferBuilder.convert(
|
||||
buffer: raw,
|
||||
using: converter,
|
||||
to: analyzerFormat
|
||||
) else {
|
||||
return
|
||||
}
|
||||
toFeed = converted
|
||||
} else {
|
||||
toFeed = raw
|
||||
}
|
||||
|
||||
inputBuilder.yield(AnalyzerInput(buffer: toFeed))
|
||||
}
|
||||
|
||||
func finish() async throws {
|
||||
partialsContinuation?.finish()
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
|
||||
inputBuilder?.finish()
|
||||
inputBuilder = nil
|
||||
|
||||
if let analyzer {
|
||||
try? await analyzer.finalizeAndFinishThroughEndOfInput()
|
||||
}
|
||||
|
||||
// Wait for the results-drain Task to flush any remaining segments.
|
||||
await resultsTask?.value
|
||||
resultsTask = nil
|
||||
}
|
||||
|
||||
func partialResults() -> AsyncStream<String> {
|
||||
let (stream, cont) = AsyncStream<String>.makeStream()
|
||||
self.partialsContinuation = cont
|
||||
return stream
|
||||
partialsStream
|
||||
}
|
||||
|
||||
func finalText() async throws -> String {
|
||||
if let err = finalizationError { throw err }
|
||||
await resultsTask?.value
|
||||
if let resultsError {
|
||||
throw resultsError
|
||||
}
|
||||
return collectedText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import AVFoundation
|
||||
import XCTest
|
||||
@testable import Voice
|
||||
|
||||
// MARK: - PCMBufferBuilderTests
|
||||
//
|
||||
// Track B-4 (2026-05-03 voice audit fix): unit-level tests for the
|
||||
// AudioChunk → AVAudioPCMBuffer translator that LiveSpeechAnalyzerBridge
|
||||
// relies on. These run on every macOS version since AVFoundation is
|
||||
// not gated on Tahoe.
|
||||
|
||||
final class PCMBufferBuilderTests: XCTestCase {
|
||||
|
||||
// MARK: - audioChunkFormat invariants
|
||||
|
||||
func test_audioChunkFormat_is_16kMonoFloat32() {
|
||||
let fmt = PCMBufferBuilder.audioChunkFormat
|
||||
XCTAssertEqual(fmt.sampleRate, 16_000, "Track B-4 contract: 16 kHz")
|
||||
XCTAssertEqual(fmt.channelCount, 1, "Track B-4 contract: mono")
|
||||
XCTAssertEqual(fmt.commonFormat, .pcmFormatFloat32, "Track B-4 contract: Float32")
|
||||
}
|
||||
|
||||
// MARK: - makePCMBuffer: frame count
|
||||
|
||||
func test_makePCMBuffer_assignsFrameLengthFromSampleCount() throws {
|
||||
let samples: [Float] = Array(repeating: 0.1, count: 256)
|
||||
let buffer = try XCTUnwrap(PCMBufferBuilder.makePCMBuffer(from: samples))
|
||||
XCTAssertEqual(Int(buffer.frameLength), 256)
|
||||
XCTAssertEqual(buffer.format.sampleRate, 16_000)
|
||||
XCTAssertEqual(buffer.format.channelCount, 1)
|
||||
}
|
||||
|
||||
// MARK: - makePCMBuffer: payload fidelity
|
||||
|
||||
func test_makePCMBuffer_copiesSamplesIntoChannelData() throws {
|
||||
let samples: [Float] = (0..<128).map { Float($0) * 0.001 }
|
||||
let buffer = try XCTUnwrap(PCMBufferBuilder.makePCMBuffer(from: samples))
|
||||
let channel = try XCTUnwrap(buffer.floatChannelData?[0])
|
||||
for i in 0..<samples.count {
|
||||
XCTAssertEqual(channel[i], samples[i], accuracy: 1e-7,
|
||||
"Sample \(i) round-trip failure")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - makePCMBuffer: empty input
|
||||
|
||||
func test_makePCMBuffer_emptyInput_returnsZeroFrameBuffer() throws {
|
||||
let buffer = try XCTUnwrap(PCMBufferBuilder.makePCMBuffer(from: []))
|
||||
XCTAssertEqual(buffer.frameLength, 0,
|
||||
"Empty AudioChunk must produce an empty buffer (caller drops, never crashes)")
|
||||
}
|
||||
|
||||
// MARK: - convert: same-format passthrough produces identical samples
|
||||
|
||||
func test_convert_sameFormat_preservesSamples() throws {
|
||||
let samples: [Float] = (0..<256).map { Float(sin(Double($0) * 0.05)) }
|
||||
let src = try XCTUnwrap(PCMBufferBuilder.makePCMBuffer(from: samples))
|
||||
let dstFormat = PCMBufferBuilder.audioChunkFormat
|
||||
let converter = try XCTUnwrap(AVAudioConverter(from: src.format, to: dstFormat))
|
||||
let out = try XCTUnwrap(PCMBufferBuilder.convert(buffer: src, using: converter, to: dstFormat))
|
||||
XCTAssertEqual(out.frameLength, src.frameLength,
|
||||
"Same-format conversion must preserve frame count")
|
||||
let outChannel = try XCTUnwrap(out.floatChannelData?[0])
|
||||
for i in 0..<Int(out.frameLength) {
|
||||
XCTAssertEqual(outChannel[i], samples[i], accuracy: 1e-6,
|
||||
"Same-format passthrough should not modify sample \(i)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - convert: 16k → 24k upsamples to ~1.5x frame count
|
||||
|
||||
func test_convert_16kTo24k_producesUpsampledFrameCount() throws {
|
||||
let samples: [Float] = Array(repeating: 0.0, count: 1600) // 100 ms @ 16k
|
||||
let src = try XCTUnwrap(PCMBufferBuilder.makePCMBuffer(from: samples))
|
||||
let dstFormat = try XCTUnwrap(AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 24_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
))
|
||||
let converter = try XCTUnwrap(AVAudioConverter(from: src.format, to: dstFormat))
|
||||
let out = try XCTUnwrap(PCMBufferBuilder.convert(buffer: src, using: converter, to: dstFormat))
|
||||
let expected = AVAudioFrameCount(Double(src.frameLength) * 1.5)
|
||||
// AVAudioConverter may produce ±1 frame at boundaries; accept ±5 frames slack.
|
||||
XCTAssertGreaterThan(out.frameLength, expected - 5,
|
||||
"Upsample should produce ~1.5x frames (got \(out.frameLength), expected ~\(expected))")
|
||||
XCTAssertLessThan(out.frameLength, expected + 5,
|
||||
"Upsample should produce ~1.5x frames (got \(out.frameLength), expected ~\(expected))")
|
||||
}
|
||||
|
||||
// MARK: - convert: empty input returns empty output
|
||||
|
||||
func test_convert_emptyInput_returnsZeroFrameBuffer() throws {
|
||||
let src = try XCTUnwrap(PCMBufferBuilder.makePCMBuffer(from: []))
|
||||
let dstFormat = PCMBufferBuilder.audioChunkFormat
|
||||
let converter = try XCTUnwrap(AVAudioConverter(from: src.format, to: dstFormat))
|
||||
let out = try XCTUnwrap(PCMBufferBuilder.convert(buffer: src, using: converter, to: dstFormat))
|
||||
XCTAssertEqual(out.frameLength, 0,
|
||||
"Empty input must not produce phantom output frames")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user