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:
james.maesandClaude Opus 4.7 committed 2026-05-03 19:05:41 -05:00
1 parent 52d0c9480e
commit e9c0a34298
6 files changed
+450 -25

No files matched your search

+98
View File
@@ -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")
}
}