fix(voice): v0.1 CRIT cluster — wake / STT / TTS / boot probes #158

Merged
KofTwentyTwo merged 8 commits from fix/voice-crit-v0.1 into develop 2026-05-13 11:31:17 +00:00
17 changed files with 964 additions and 20 deletions

No files matched your search

+165 -7
View File
@@ -561,6 +561,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// `emitTurnEnded` / `emitError` hooks. (Plan 09-04.)
private var voiceOrchestratorAdapter: VoiceOrchestratorAdapter?
/// Stored handle to the wake-word DAG so `WakeWordBootHealthProbe`
/// (audit-2026-05-12 P1-2 / Issue #33) can query `isFeedArmed`.
/// Nil before `installVoice` succeeds (model files missing, etc.).
private var wakeWordDAG: WakeWordDAG?
/// Stored handle to the production TTS adapter so
/// `TTSBootHealthProbe` can query whether a real `TTSEngineActor`
/// is wired. Nil before `installVoice` succeeds.
/// (audit-2026-05-12 P1-2 / Issue #33.)
private var voiceTTSAdapter: VoiceTTSAdapter?
/// Drains the broadcaster's `.voice` priority subscription. Maintains
/// a per-turn assistant-text accumulator gated by
/// `agentOrchestrator.turnSourceWasVoice` — text-originated turns
@@ -586,6 +597,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// Phase E hoist.)
private var outboundBatcher: OutboundBatcher?
/// Drains the broadcaster's `.agentHud` priority subscription and
/// maps `.stateChange` / `.turnEnd` to `AgentHudIntent` values
/// yielded into the dormant continuation. Pre-fix, the
/// `dormantAgentContinuation` was constructed but never yielded —
/// the HUD ring stayed `.silent` during thinking and speaking.
/// (audit-2026-05-12 P1-5 / Issue #36.)
private var agentHudSubscriberTask: Task<Void, Never>?
/// Filename of the HTML entry the webview loads. Production value is
/// `"index"` (the R3F bundle); `installBus` assigns it on construction.
/// Test seam — XCTest asserts the post-install value to confirm the
@@ -1069,6 +1088,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
VoiceBootHealthProbe(audioGraphOwner: self.audioGraphOwner)
)
// Three voice-watchdog probes — fill the gap surfaced by
// audit-2026-05-12 P1-2 / Issue #33. `VoiceBootHealthProbe`
// alone checked `audioGraphOwner` + `micStatus` + `currentVariant`
// and reported `ok` even after the wake-word DAG died (P0-1) or
// TTS was never wired (Track B-3 pre-fix). These probes
// distinguish "voice is plumbed" from "voice will actually work".
await bootHealthOrchestrator.register(
WakeWordBootHealthProbe(isArmed: { [weak self] in
guard let dag = await MainActor.run(body: { self?.wakeWordDAG })
else { return nil }
return await dag.isFeedArmed
})
)
await bootHealthOrchestrator.register(STTBootHealthProbe())
await bootHealthOrchestrator.register(
TTSBootHealthProbe(engineAlive: { [weak self] in
guard let adapter = await MainActor.run(body: { self?.voiceTTSAdapter })
else { return false }
return await adapter.hasEngine
})
)
await bootHealthOrchestrator.register(VisionBootHealthProbe())
await bootHealthOrchestrator.register(
@@ -1221,6 +1264,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
frameAttachReleaseTask?.cancel()
voiceEventTranslatorTask?.cancel()
busSubscriberTask?.cancel()
agentHudSubscriberTask?.cancel()
if let broadcaster = eventBroadcaster {
Task { await broadcaster.stop() }
}
@@ -1297,6 +1341,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
let wakeWordDAG = WakeWordDAG(session: wakeWordSession)
self.wakeWordDAG = wakeWordDAG // expose to BootHealthProbe (Issue #33)
// The degradation + rebuild consumer Tasks MUST be spawned before
// `graphOwner.open()` because `open()` may immediately yield
@@ -1333,9 +1378,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// `.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
//
// Re-arm the wake-word DAG against the new ring on every rebuild.
// The `cancelInFlight` slot (set below) calls `stopFeed()` to halt
// the old feed task; the public `wakeWordStream` survives, but the
// DAG has no producer until we call `start(ring:)` again. Without
// this, wake-word stops working after any device-change /
// mic-regrant / ring-overflow event.
// (audit-2026-05-12 P0-1 / Issue #28.)
audioGraphRebuildTask = Task { @MainActor [weak self, wakeWordDAG] in
for await _ in rebuildStream {
guard let self else { return }
if let newRing = await self.audioGraphOwner?.ringBuffer {
await wakeWordDAG.start(ring: newRing)
}
let variant = await self.audioGraphOwner?.currentVariant
if case .aecOn = variant {
await self.voiceController?.handleAECRestored()
@@ -1399,8 +1455,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Cancel wake-word inference before the engine stops — otherwise
// the detached feed Task races the ring deallocation during graph
// rebuilds. (VOICE-10.)
//
// Use `stopFeed()` (not `cancel()`) so the public wakeWordStream
// survives the rebuild — `VoiceController.spawnWakeWordConsumer`
// keeps iterating across the boundary. The rebuild completion
// consumer below re-arms the DAG against the new ring.
// (audit-2026-05-12 P0-1 / Issue #28: cancel() permanently
// finished the stream, killing wake-word on first device-change /
// mic-regrant / ring-overflow event.)
await graphOwner.setCancelInFlight { [wakeWordDAG] in
await wakeWordDAG.cancel()
await wakeWordDAG.stopFeed()
}
// The production adapter triad replaces the three earlier Null
@@ -1438,7 +1502,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// wires alone here; tier-2 (Orpheus) is gated behind the ~6 GB
// weight download and degrades to tier-1 transparently.
// (audit trail: 2026-05-03 voice audit / Track B-3.)
let ttsAdapter = VoiceTTSAdapter(engine: VoiceOutputWiring.makeTier1Engine())
// Resolve TTS tier from PerTurnSnapshot per synthesis. Defaults
// to `.tier1` when configStore is nil or `tts.tier` is anything
// other than "tier2". Today this is decorative — Orpheus weights
// aren't shipped, so the engine resolves tier-2 back to tier-1
// inside `synthesize` — but the moment weights arrive, this
// closure becomes the user-facing tier switch.
// (audit-2026-05-12 P0-4 / Issue #31: was unwired; the default
// `{ .tier1 }` constant masked any `tts.tier = "tier2"` config.)
let ttsAdapter = VoiceTTSAdapter(
engine: VoiceOutputWiring.makeTier1Engine(),
tierResolver: { [configStore = self.configStore] in
guard let configStore else { return .tier1 }
let snap = await configStore.perTurn()
return snap.tts.tier == "tier2" ? .tier2 : .tier1
}
)
self.voiceTTSAdapter = ttsAdapter // expose to BootHealthProbe (Issue #33)
// Reuse the `OutboundBatcher` constructed by `installAgent`. The
// batcher coalesces high-frequency audio-level RMS at ~30 Hz
@@ -1473,7 +1553,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let vc = VoiceController(
wakeWordStream: wakeWordDAG.wakeWordStream,
vadFactory: { sileroVAD },
sttFactory: { STTBackendSelector.make(backend: "speech_analyzer") },
sttFactory: { [configStore = self.configStore] in
// Read STT backend from PerTurnSnapshot. Defaults to
// SpeechAnalyzer when the snapshot disables WhisperKit
// fallback (today's default) or when configStore is nil.
// (audit-2026-05-12 P0-2 / Issue #29: was hard-coded to
// "speech_analyzer", making `stt.whisperKitFallback`
// unreachable from config.)
let backend: String
if let configStore {
let snap = await configStore.perTurn()
backend = snap.stt.whisperKitFallback
? STTBackendSelector.backendWhisperKit
: STTBackendSelector.backendSpeechAnalyzer
} else {
backend = STTBackendSelector.backendSpeechAnalyzer
}
return STTBackendSelector.make(backend: backend)
},
tts: ttsAdapter,
orchestrator: orchAdapter,
bannerCoordinator: bannerAdapter,
@@ -2154,8 +2251,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
// 5g. Agent HUD intent pump (audit-2026-05-12 P1-5 / Issue #36).
// Pre-fix the `dormantAgentContinuation` was constructed but
// never yielded into — `grep -rn dormantAgentContinuation App/`
// returned only the declaration + assignment. Combined with
// `VoiceController.hudIntentFor` mapping `.thinking` /
// `.speaking` to `.silent`, the HUD ring saw `.listening` on
// wake, `.reconfiguring` during rebuilds, and `.silent`
// everywhere else. Thinking and speaking looked identical to
// idle.
//
// This subscriber maps the orchestrator's `.stateChange` /
// `.turnEnd` to `AgentHudIntent` values yielded into the
// continuation so the HUD ring reflects agent activity. The
// `.agentHud` priority protects `.stateChange` / `.turnEnd` /
// `.error` — the ring's lifecycle transitions can't be
// tail-dropped under overload.
let agentHudSub = await broadcaster.subscribe(priority: .agentHud, capacity: 32)
self.agentHudSubscriberTask = Task { [weak self] in
guard let self else { return }
// Capture the continuation on entry; the `dormantAgentContinuation`
// is `@MainActor`-isolated so reads need an actor hop.
let cont = await MainActor.run { self.dormantAgentContinuation }
guard let cont else { return }
for await event in agentHudSub.stream {
if Task.isCancelled { break }
switch event {
case .stateChange(let state):
// Translate TurnState → AgentHudIntent. Tier-1 TTS
// (AVSpeech) emits the user-audible portion of an
// assistant reply; today we don't have a distinct
// `.speaking` state on the orchestrator (it stays
// `.thinking` until terminal `.idle`), so map
// everything non-idle/non-reconfiguring to `.thinking`.
// M-6 wires a richer state machine.
switch state {
case .idle: cont.yield(.idle)
case .thinking: cont.yield(.thinking)
case .speaking: cont.yield(.speaking)
case .booting, .listening,
.awaitingConfirmation,
.reconfiguring:
break // HUD owns these from other sources
}
case .turnEnd, .error:
cont.yield(.idle)
default:
break
}
}
}
systemLogger?.info(
"installAgent: AgentOrchestrator + broadcaster + transcript store wired (memory + transcript + devOverlay + frameAttach + voice + bus subscribers active)"
"installAgent: AgentOrchestrator + broadcaster + transcript store wired (memory + transcript + devOverlay + frameAttach + voice + bus + agentHud subscribers active)"
)
}
@@ -2262,9 +2410,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return snap.tts.tier
}
let sttBackend: @Sendable () async -> String = {
guard let configStore else { return "speechAnalyzer" }
// Returns the canonical backend identifier matching
// `STTBackendSelector.backendSpeechAnalyzer` /
// `STTBackendSelector.backendWhisperKit` (snake_case). The
// pre-2026-05-12 strings ("speechAnalyzer" / "whisperKit"
// camelCase) did not match the selector's accepted values —
// any consumer routing through `STTBackendSelector.make`
// landed in the `unknown backend` default branch.
// (audit-2026-05-12 P0-2 / P2-1 / Issue #29.)
guard let configStore else { return STTBackendSelector.backendSpeechAnalyzer }
let snap = await configStore.perTurn()
return snap.stt.whisperKitFallback ? "whisperKit" : "speechAnalyzer"
return snap.stt.whisperKitFallback
? STTBackendSelector.backendWhisperKit
: STTBackendSelector.backendSpeechAnalyzer
}
let wakeWordMuted: @Sendable () async -> Bool = {
UserDefaults.standard.bool(forKey: "features.voice.wakeWordMuted")
+120
View File
@@ -1,6 +1,7 @@
import Foundation
import AppKit
import AVFoundation
import Speech
import AgentCore
import Bus
import Config
@@ -265,6 +266,125 @@ public struct VoiceBootHealthProbe: BootHealthProbe {
}
}
// MARK: - WakeWord (Issue #33)
/// Watchdog for the wake-word DAG. Status `.ok` only when the feed Task
/// is armed against a ring. `.failed` when the DAG is dormant — fires
/// after audit-2026-05-12 P0-1's silent-death scenario (DAG cancelled
/// via `cancel()` and never re-armed).
///
/// Constructed against an optional `@Sendable` closure so the AppDelegate
/// can resolve the DAG lazily — `installVoice` may not have constructed
/// the DAG yet at probe-registration time (model files absent), and we
/// want one probe shape across "DAG armed" / "DAG dormant" / "DAG
/// never installed".
public struct WakeWordBootHealthProbe: BootHealthProbe {
public let name = "wake_word"
private let isArmed: @Sendable () async -> Bool?
/// - Parameter isArmed: closure returning `true` when the DAG's
/// feed Task is armed, `false` when stopped, `nil` when the DAG
/// was never installed (e.g. models missing).
public init(isArmed: @escaping @Sendable () async -> Bool?) {
self.isArmed = isArmed
}
public func probe() async -> ProbeOutcome {
guard let armed = await isArmed() else {
return ProbeOutcome(
status: .unknown(reason: "wake-word DAG not installed (models absent or mic denied)", severity: .soft),
evidence: "WakeWordDAG handle unavailable"
)
}
if armed {
return ProbeOutcome(status: .ok, evidence: "feed Task armed")
}
return ProbeOutcome(
status: .failed(reason: "wake-word DAG feed Task not armed", severity: .loud),
evidence: "WakeWordDAG.isFeedArmed=false — wake-word will not fire until re-armed"
)
}
}
// MARK: - STT (Issue #33)
/// Probes Apple Speech recognition authorization status as a proxy for
/// SpeechAnalyzer asset availability. macOS 26 SpeechAnalyzer requires
/// the `com.apple.developer.speech-recognition-assets` entitlement +
/// `NSSpeechRecognitionAssetsUsageDescription` Info.plist key for
/// on-device asset download; missing either causes silent
/// `SFSpeechErrorCode.assetUnavailable` on first transcription.
///
/// `SFSpeechRecognizer.authorizationStatus()` returns synchronously
/// without TCC prompts (it just reads the current state), so this
/// probe is safe to run at boot.
public struct STTBootHealthProbe: BootHealthProbe {
public let name = "stt"
public init() {}
public func probe() async -> ProbeOutcome {
let status = SFSpeechRecognizer.authorizationStatus()
switch status {
case .authorized:
return ProbeOutcome(
status: .ok,
evidence: "SFSpeechRecognizer authorized"
)
case .denied, .restricted:
return ProbeOutcome(
status: .degraded(reason: "speech recognition TCC denied", severity: .loud),
evidence: "SFSpeechRecognizer status=\(String(describing: status))"
)
case .notDetermined:
return ProbeOutcome(
status: .unknown(reason: "speech recognition TCC not yet requested", severity: .soft),
evidence: "SFSpeechRecognizer status=notDetermined"
)
@unknown default:
return ProbeOutcome(
status: .unknown(reason: "unknown SFSpeechRecognizer status \(status.rawValue)", severity: .soft),
evidence: "SFSpeechRecognizer.authorizationStatus returned unrecognized case"
)
}
}
}
// MARK: - TTS (Issue #33)
/// Cheap watchdog for the TTS engine: verifies the adapter is wired
/// (engine non-nil). The prior `VoiceBootHealthProbe` reported `ok`
/// based only on `audioGraphOwner` + `micStatus`, so a `nil`-engine
/// adapter (the pre-Track-B-3 production state) never surfaced.
///
/// The probe doesn't actually synthesize at boot — the audit suggests
/// "synthesize empty string" as one option but that would emit
/// audible events into a not-yet-ready audio chain. Cheaper, safer:
/// check whether the adapter has an engine attached. The AppDelegate
/// closure returns true when the TTS adapter was constructed with a
/// real `TTSEngineActor` and false when the dormant path is wired.
public struct TTSBootHealthProbe: BootHealthProbe {
public let name = "tts"
private let engineAlive: @Sendable () async -> Bool
public init(engineAlive: @escaping @Sendable () async -> Bool) {
self.engineAlive = engineAlive
}
public func probe() async -> ProbeOutcome {
if await engineAlive() {
return ProbeOutcome(
status: .ok,
evidence: "TTSEngineActor wired"
)
}
return ProbeOutcome(
status: .degraded(reason: "TTS engine not wired (adapter dormant)", severity: .loud),
evidence: "VoiceTTSAdapter.engine=nil — synth calls no-op"
)
}
}
// MARK: - Vision
/// Camera TCC status + camera device enumeration. No camera frame is
@@ -102,6 +102,54 @@ final class VoiceOrchestratorAdapterTests: XCTestCase {
await adapter.cancelTTS()
}
// MARK: - VoiceTTSAdapter tierResolver wiring (Issue #31)
/// Regression for audit-2026-05-12 P0-4 / Issue #31: AppDelegate
/// constructed the adapter with `engine:` only, leaving
/// `tierResolver` at the default `{ .tier1 }` constant. Setting
/// `tts.tier = "tier2"` in config did nothing for real synthesis.
/// The fix wires a closure that reads `PerTurnSnapshot.tts.tier`.
///
/// This test asserts the adapter calls its resolver on every
/// `synthesize` call, so a config flip on the next synthesis lands.
/// The engine is nil (no real synthesis runs); we just measure that
/// `synthesize` *would have* asked the resolver before routing.
///
/// With `engine: nil` the adapter no-ops before invoking the
/// resolver, so we use a non-nil engine sentinel. The simplest
/// stand-in is `TTSEngineActor(orpheus: nil, tier1: AVSpeechSynth(),
/// fallback: nil)` — a real tier-1 engine. We synthesize an empty
/// string to keep the test fast.
func testVoiceTTSAdapterTierResolverIsInvokedPerSynthesis() async throws {
actor ResolverCallCount {
var count = 0
func tick() { count += 1 }
}
let counter = ResolverCallCount()
let engine = TTSEngineActor(
orpheus: nil,
tier1: AVSpeechSynth(),
fallback: nil
)
let adapter = VoiceTTSAdapter(
engine: engine,
tierResolver: {
await counter.tick()
return .tier1
}
)
await adapter.synthesize("a")
await adapter.synthesize("b")
let calls = await counter.count
XCTAssertEqual(
calls, 2,
"tierResolver must be invoked once per `synthesize` call (Issue #31). Got \(calls)."
)
}
// MARK: - VoiceBusEmitterAdapter (forwards to batcher)
func testVoiceBusEmitterAdapterForwardsRMSToBatcher() async throws {
@@ -0,0 +1,91 @@
import XCTest
@testable import Jarvis
@testable import Voice
/// Coverage for the three voice watchdog probes added in
/// audit-2026-05-12 P1-2 / Issue #33. Each probe distinguishes "voice
/// plumbed" from "voice will actually work" — the prior
/// `VoiceBootHealthProbe` reported `ok` even after wake-word died
/// (P0-1) or TTS was never wired.
final class VoiceWatchdogProbesTests: XCTestCase {
// MARK: - WakeWordBootHealthProbe
/// W1: nil isArmed (DAG never installed) → `.unknown` soft.
func test_W1_wakeWord_nilDAG_isUnknownSoft() async {
let probe = WakeWordBootHealthProbe(isArmed: { nil })
let outcome = await probe.probe()
switch outcome.status {
case .unknown(_, let severity):
XCTAssertEqual(severity, .soft, "DAG-never-installed must be soft severity")
default:
XCTFail("expected .unknown for nil DAG, got \(outcome.status)")
}
}
/// W2: armed feed → `.ok`.
func test_W2_wakeWord_armed_isOk() async {
let probe = WakeWordBootHealthProbe(isArmed: { true })
let outcome = await probe.probe()
switch outcome.status {
case .ok:
break
default:
XCTFail("expected .ok for armed feed, got \(outcome.status)")
}
}
/// W3 (Issue #33 regression): DAG installed but feed not armed →
/// `.failed` loud. This is the canonical post-P0-1 silent-death
/// scenario the watchdog must catch.
func test_W3_wakeWord_notArmed_isFailedLoud() async {
let probe = WakeWordBootHealthProbe(isArmed: { false })
let outcome = await probe.probe()
switch outcome.status {
case .failed(_, let severity):
XCTAssertEqual(severity, .loud, "DAG-dead must be loud severity")
default:
XCTFail("expected .failed for dead DAG, got \(outcome.status). Evidence: \(outcome.evidence)")
}
}
// MARK: - TTSBootHealthProbe
/// T1: engine wired → `.ok`.
func test_T1_tts_engineAlive_isOk() async {
let probe = TTSBootHealthProbe(engineAlive: { true })
let outcome = await probe.probe()
switch outcome.status {
case .ok:
break
default:
XCTFail("expected .ok for live engine, got \(outcome.status)")
}
}
/// T2 (Issue #33 regression): engine nil (dormant adapter) →
/// `.degraded` loud. Pre-Track-B-3, `VoiceTTSAdapter(engine: nil)`
/// was the production state and synth calls no-op'd silently. The
/// watchdog must surface that today.
func test_T2_tts_engineNil_isDegradedLoud() async {
let probe = TTSBootHealthProbe(engineAlive: { false })
let outcome = await probe.probe()
switch outcome.status {
case .degraded(_, let severity):
XCTAssertEqual(severity, .loud, "dormant TTS must be loud severity")
default:
XCTFail("expected .degraded for nil engine, got \(outcome.status)")
}
}
// MARK: - STTBootHealthProbe
/// S1: STT probe runs and produces some outcome. The exact status
/// depends on the host's `SFSpeechRecognizer` authorization state;
/// we just assert the probe doesn't crash and produces evidence.
func test_S1_stt_probeRunsWithoutCrash() async {
let probe = STTBootHealthProbe()
let outcome = await probe.probe()
XCTAssertFalse(outcome.evidence.isEmpty, "STT probe must produce evidence")
}
}
+5
View File
@@ -41,6 +41,11 @@ actor VoiceTTSAdapter: VoiceTTSInterface {
}
}
/// Whether a real `TTSEngineActor` is wired (vs. the dormant
/// `engine: nil` path). Used by `TTSBootHealthProbe`
/// (audit-2026-05-12 P1-2 / Issue #33).
var hasEngine: Bool { engine != nil }
func synthesize(_ text: String) async {
guard let engine else { return } // dormant — no engine wired yet
let tier = await tierResolver()
@@ -54,6 +54,18 @@ public actor OrchestratorEventBroadcaster {
/// emitted to webview) by giving AppDelegate a canonical
/// priority to subscribe under for bus-forwarding.
case bus
/// HUD-state subscriber — drains `.stateChange` / `.turnEnd` to
/// the dormant `AgentHudIntent` continuation so the HUD ring
/// reflects agent activity (thinking / speaking / idle).
/// `.stateChange` / `.turnEnd` are protected; all other events
/// (tokenDelta / thinkingDelta / usage / toolCardUpdate / error)
/// are drop-eligible — the HUD doesn't render tokens itself.
///
/// (audit-2026-05-12 P1-5 / Issue #36: prior to this priority,
/// the `dormantAgentContinuation` was constructed but never
/// yielded into — the HUD ring stayed `.silent` during
/// thinking and speaking.)
case agentHud
}
private let upstream: BoundedAsyncChannel<OrchestratorEvent>
@@ -176,6 +188,14 @@ public actor OrchestratorEventBroadcaster {
case .turnEnd, .error: return true
default: return false
}
case .agentHud:
// HUD-state subscriber — protect lifecycle events; tokens,
// thinking-text and tool cards aren't needed for ring
// transitions and can drop under overload.
switch event {
case .stateChange, .turnEnd, .error: return true
default: return false
}
}
}
}
@@ -298,6 +298,20 @@ final class OrchestratorEventBroadcasterTests: XCTestCase {
".bus priority protection wrong for \(label)"
)
}
// agentHud (audit-2026-05-12 P1-5 / Issue #36): protects
// .stateChange, .turnEnd, .error — these drive ring lifecycle
// transitions and must not be tail-dropped. All other classes
// (tokenDelta / thinkingDelta / toolCardUpdate / usage) are
// drop-eligible — the HUD doesn't render them.
for (label, event) in allEvents {
let isProtected = await broadcaster.isProtectedForPriority(event, priority: .agentHud)
let expected = (label == "stateChange" || label == "turnEnd" || label == "error")
XCTAssertEqual(
isProtected, expected,
".agentHud priority protection wrong for \(label)"
)
}
}
// MARK: - Helpers
@@ -19,7 +19,11 @@ import MLXAudioCore
// - fallback: TTSKit (feature-flag gated, defaulted off; Plan 06-05 wires flag).
//
// Event stream: `ttsEventStream` emits TTSEvent values for HUD animation +
// ducking control. Ducking releases ONLY on `.ttsStopped` (VOICE-11 / Pitfall #6).
// ducking control. Ducking releases on `.ttsStopped` — emitted both on
// natural completion (after `.finished`, inside `synthesize`) and on
// barge-in (via `InterruptSequence.run`). Prior to audit-2026-05-12 P0-3
// (Issue #30) only the barge-in site emitted `.ttsStopped`, so natural
// completion left any future ducking consumer with ducks engaged forever.
public actor TTSEngineActor {
@@ -27,8 +31,17 @@ public actor TTSEngineActor {
/// Event stream for HUD animation and ducking control.
///
/// VOICE-11 / Pitfall #6: Ducking releases ONLY on `.ttsStopped`.
/// DO NOT release ducking on `.finished` — producer done ≠ sink empty.
/// VOICE-11 / Pitfall #6: Ducking releases on `.ttsStopped`,
/// NOT on `.finished` — producer done ≠ sink empty.
/// `.ttsStopped` is emitted from two sites:
/// - Natural completion: `synthesize(...)` yields it after
/// `.finished` once the tier path has fully drained.
/// - Barge-in: `InterruptSequence.run(...)` yields it at step 5
/// after the 5-step interrupt sequence has run.
/// (audit-2026-05-12 P0-3 / Issue #30 added the natural-completion
/// emit; before then only barge-in fired `.ttsStopped`, so any
/// ducking consumer would have left ducks engaged forever on a
/// normal spoken reply.)
public nonisolated let ttsEventStream: AsyncStream<TTSEvent>
// MARK: - Private state
@@ -165,6 +178,8 @@ public actor TTSEngineActor {
try await task.value
} catch is CancellationError {
_hasSynthInFlight = false
// Cancellation flows through `InterruptSequence.run`, which
// emits `.ttsStopped` at step 5. Don't double-emit here.
throw TTSError.cancelled
} catch let e as TTSError {
_hasSynthInFlight = false
@@ -175,6 +190,13 @@ public actor TTSEngineActor {
}
_hasSynthInFlight = false
currentTask = nil
// Natural-completion `.ttsStopped` emit (audit-2026-05-12 P0-3 /
// Issue #30). The barge-in path emits `.ttsStopped` from
// `InterruptSequence.run` step 5 instead. Together these are
// the two sites that release ducking — see `ttsEventStream`
// doc-comment.
eventContinuation.yield(.ttsStopped)
}
// MARK: - Cancel
@@ -40,9 +40,19 @@ public protocol InterruptStepRecorder: Sendable {
// 5. Emit `.ttsStopped` — THIS is the ducking-release gate
//
// VOICE-11 / Pitfall #6:
// `.ttsStopped` at step 5 is the ONLY site that triggers ducking release.
// DO NOT release ducking on `.finished` — producer done ≠ sink empty.
// The 100–200 ms tail of scheduled buffers would clip if ducking released early.
// `.ttsStopped` is the ducking-release gate (NEVER `.finished` —
// producer done ≠ sink empty; the 100–200 ms tail of scheduled
// buffers would clip if ducking released early).
//
// Two emit sites cooperate to cover the full lifecycle:
// - Natural completion: `TTSEngineActor.synthesize` yields it
// after `.finished` when the tier path has fully drained.
// - Barge-in: this `InterruptSequence.run` yields it at step 5
// after the 5-step interrupt sequence has run.
//
// Before audit-2026-05-12 P0-3 (Issue #30) only this barge-in site
// emitted `.ttsStopped`, so natural completion left any future
// ducking consumer with ducks engaged forever on a normal reply.
//
// Idempotency:
// If `engine.hasSynthInFlight == false`, the sequence is a no-op.
@@ -92,10 +102,10 @@ public enum InterruptSequence {
group.cancelAll()
}
// STEP 5: Emit .ttsStopped — THE ducking-release gate (VOICE-11 / Pitfall #6).
// This is the ONLY site that emits .ttsStopped (single source of truth).
// grep gate: grep -nE '\.ttsStopped' TTSInterrupt.swift | grep -v '//' | wc -l == 1
// STEP 5: Emit .ttsStopped — the barge-in ducking-release gate.
// (Natural completion emits the corresponding `.ttsStopped` from
// `TTSEngineActor.synthesize`; see the file-level note above.)
stepLog?.record("5-ttsStopped")
eventBus.yield(.ttsStopped) // single .ttsStopped emission site
eventBus.yield(.ttsStopped)
}
}
@@ -48,7 +48,7 @@ public actor VoiceController {
private let wakeWordStream: AsyncStream<WakeWordEvent>
private let vadFactory: @Sendable () -> SileroVAD
private let sttFactory: @Sendable () -> any STTProvider
private let sttFactory: @Sendable () async -> any STTProvider
private let chunkPump: @Sendable (AsyncStream<AudioChunk>.Continuation) async -> Void
private let tts: any VoiceTTSInterface
private let orchestrator: any VoiceOrchestratorInterface
@@ -126,7 +126,7 @@ public actor VoiceController {
public init(
wakeWordStream: AsyncStream<WakeWordEvent>,
vadFactory: @escaping @Sendable () -> SileroVAD,
sttFactory: @escaping @Sendable () -> any STTProvider,
sttFactory: @escaping @Sendable () async -> any STTProvider,
tts: any VoiceTTSInterface,
orchestrator: any VoiceOrchestratorInterface,
bannerCoordinator: any VoiceBannerInterface,
@@ -395,7 +395,7 @@ public actor VoiceController {
sttSessionId &+= 1
let sessionId = sttSessionId
let provider = sttFactory()
let provider = await sttFactory()
let (chunkStream, cont) = AsyncStream<AudioChunk>.makeStream()
sttChunkCont = cont
@@ -424,6 +424,14 @@ public actor VoiceController {
}
let vad = vadFactory()
// Reset Silero LSTM hidden state between sessions
// (audit-2026-05-12 P1-3 / Issue #34). The vadFactory closure
// typically caches a single `SileroVAD` instance per process —
// without `reset()`, hidden state from session N's `.speechEnd`
// (encoding "we just exited speech") biases session N+1's
// first 1–3 windows toward `.silence`, dropping soft-spoken
// opening syllables from STT input.
vad.reset()
let hangover = Self.vadHangoverChunks
vadInterceptorTask = Task { [weak self] in
await self?.runVADInterceptor(
@@ -192,6 +192,35 @@ public actor OpenWakeWordSession {
nextEmbStart += embStride
}
// Rolling-window trim (audit-2026-05-12 P1-1 / Issue #32). The
// streaming path appended to both buffers append-only — at one
// mel frame per 80 ms `feed` (production cadence), 24 h of
// always-on listening accumulated ~130 MB of mel frames + ~50 MB
// of embeddings retained forever. The leak was masked today by
// `WakeWordDAG` cancelling on every audio-graph rebuild, but
// closing #28 exposes it: fix one without the other and memory
// grows unbounded across long sessions.
//
// Trim policy:
// - Keep last `76 + embStride` mel frames so the next embedding
// window has full context after the trim.
// - Keep last 16 embeddings to match the classifier's
// `suffix(16)` read.
// - When mel frames are dropped, slide `nextEmbStart` left by
// the same count so indexing remains valid (it indexes into
// `melFrameBuffer`).
let melKeep = 76 + embStride
if melFrameBuffer.count > melKeep {
let drop = melFrameBuffer.count - melKeep
melFrameBuffer.removeFirst(drop)
// Shift `nextEmbStart` to stay aligned with the trimmed buffer.
// Clamp at 0 in case more was dropped than the prior index.
nextEmbStart = max(0, nextEmbStart - drop)
}
if embeddingBuffer.count > 16 {
embeddingBuffer.removeFirst(embeddingBuffer.count - 16)
}
// Run classifier on the most recent 16 embeddings.
guard embeddingBuffer.count >= 16 else {
return 0 // not enough context yet — below threshold by definition
@@ -384,6 +413,45 @@ public actor OpenWakeWordSession {
/// 96-dim embeddings accumulated across `feed()` calls. The classifier
/// reads the most recent 16.
private var embeddingBuffer: [[Float]] = []
// MARK: - Test seams (internal — for Issue #32 unbounded-growth tests)
/// Test seam: simulate the streaming-buffer append + trim path without
/// requiring real ORT models. Mirrors the post-`runMel` /
/// post-`runEmbedding` writes in `runORT`, then applies the same
/// rolling-window trim. The classifier read is skipped — the caller
/// is testing buffer bounds, not detection.
///
/// Use only from tests; production must go through `feed(samples:)`.
internal func _testInjectFrame(
mels: [[Float]],
embeddingsToAppend: [[Float]] = []
) {
melFrameBuffer.append(contentsOf: mels)
for emb in embeddingsToAppend {
embeddingBuffer.append(emb)
}
// Mirror `runORT`'s `nextEmbStart` advance + trim, so a stream of
// injected frames behaves like the production loop without ORT.
let embStride = 8
while melFrameBuffer.count >= 76 + nextEmbStart {
nextEmbStart += embStride
}
let melKeep = 76 + embStride
if melFrameBuffer.count > melKeep {
let drop = melFrameBuffer.count - melKeep
melFrameBuffer.removeFirst(drop)
nextEmbStart = max(0, nextEmbStart - drop)
}
if embeddingBuffer.count > 16 {
embeddingBuffer.removeFirst(embeddingBuffer.count - 16)
}
}
/// Test seam: read current streaming-buffer sizes for bound assertions.
internal var _testBufferSizes: (melFrames: Int, embeddings: Int, nextEmbStart: Int) {
(melFrameBuffer.count, embeddingBuffer.count, nextEmbStart)
}
}
// MARK: - Supporting types
@@ -161,6 +161,18 @@ public actor WakeWordDAG {
logger.info("WakeWordDAG: feed stopped (stream preserved for rebuild)")
}
/// Whether a feed Task is currently armed against a ring.
///
/// Used by `VoiceBootHealthProbe` (audit-2026-05-12 P1-2 / Issue #33)
/// as a cheap watchdog — the prior probe only checked
/// `currentVariant != nil` and reported `ok` even after `cancel()`
/// had finished the stream. `true` here means a feed Task exists and
/// hasn't been cancelled.
public var isFeedArmed: Bool {
guard let task = feedTask else { return false }
return !task.isCancelled
}
// MARK: - Private state
private let session: OpenWakeWordSession
@@ -0,0 +1,98 @@
import XCTest
@testable import Voice
/// Coverage for the rolling-window trim of `OpenWakeWordSession`'s
/// streaming buffers (audit-2026-05-12 P1-1 / Issue #32).
///
/// Pre-fix: `melFrameBuffer` and `embeddingBuffer` were append-only.
/// At production cadence (1 mel frame per 80 ms `feed`), 24 h of
/// always-on listening accumulated ~130 MB of mel frames + ~50 MB of
/// embeddings retained forever. The leak was masked because
/// `WakeWordDAG` cancelled on every audio-graph rebuild; closing
/// #28 (rebuild no longer kills wake-word) makes the leak visible.
///
/// Post-fix invariants:
/// - `melFrameBuffer.count <= 76 + embStride` (= 84)
/// - `embeddingBuffer.count <= 16`
/// - `nextEmbStart` never points past `melFrameBuffer.count`
/// (it is shifted left when frames are trimmed)
final class OpenWakeWordBufferBoundsTests: XCTestCase {
/// Construct a session with the scripted-classifier init so we can
/// inject frames without requiring ORT model files.
private func makeSession() -> OpenWakeWordSession {
return OpenWakeWordSession(scriptedClassifier: { _ in 0.0 })
}
/// Synthetic mel frame: 32 floats (correct band count for the
/// openWakeWord mel stage). Value content is irrelevant for the
/// trim — only counts and indices matter.
private func makeFrame() -> [Float] {
return [Float](repeating: 0.0, count: 32)
}
/// Synthetic 96-dim embedding (matches the embedding stage output).
private func makeEmbedding() -> [Float] {
return [Float](repeating: 0.0, count: 96)
}
/// B1: 10 000 simulated frames must leave both buffers bounded.
/// Pre-fix this would have stored all 10 000 mel frames + ~10 000
/// embeddings.
func test_B1_streamingBuffersStayBoundedUnderHeavyLoad() async {
let session = makeSession()
// Inject 10 000 single-frame batches with one embedding each.
for _ in 0..<10_000 {
await session._testInjectFrame(
mels: [makeFrame()],
embeddingsToAppend: [makeEmbedding()]
)
}
let sizes = await session._testBufferSizes
XCTAssertLessThanOrEqual(
sizes.melFrames, 76 + 8,
"melFrameBuffer must be bounded at 76 + embStride (= 84) after rolling-window trim"
)
XCTAssertLessThanOrEqual(
sizes.embeddings, 16,
"embeddingBuffer must be bounded at 16 after rolling-window trim"
)
XCTAssertGreaterThanOrEqual(
sizes.nextEmbStart, 0,
"nextEmbStart must remain non-negative after trim"
)
XCTAssertLessThanOrEqual(
sizes.nextEmbStart, sizes.melFrames,
"nextEmbStart must remain a valid index into the trimmed melFrameBuffer"
)
}
/// B2: short-stream case (fewer than the trim threshold) must keep
/// every frame — the trim must only kick in once we exceed the
/// rolling-window bound. This pins the contract that the trim
/// preserves classifier context.
func test_B2_shortStreamRetainsAllFrames() async {
let session = makeSession()
// Inject 10 mel frames + 5 embeddings — well below the trim
// thresholds (84 / 16). Expect both to be retained verbatim.
for _ in 0..<10 {
await session._testInjectFrame(mels: [makeFrame()])
}
for _ in 0..<5 {
await session._testInjectFrame(mels: [], embeddingsToAppend: [makeEmbedding()])
}
let sizes = await session._testBufferSizes
XCTAssertEqual(
sizes.melFrames, 10,
"short streams below the trim threshold must keep every mel frame"
)
XCTAssertEqual(
sizes.embeddings, 5,
"short streams below the trim threshold must keep every embedding"
)
}
}
@@ -225,6 +225,45 @@ final class STTBackendSelectorTests: XCTestCase {
XCTAssertTrue(stt is SpeechAnalyzerSTT,
"Empty backend string should return SpeechAnalyzerSTT (default)")
}
// MARK: - B5 (Issue #29): canonical constants round-trip through `make`
/// Regression: the canonical constants exported by `STTBackendSelector`
/// (snake_case `speech_analyzer` / `whisperkit`) must round-trip
/// through `make(backend:)` without falling into the unknown-backend
/// default branch. The 2026-05-12 audit found `get_self_state`
/// returning camelCase strings (`"speechAnalyzer"` / `"whisperKit"`),
/// which DO match the constants here only when callers use the
/// constants — string-literal mismatches landed in the default
/// branch silently. This test pins the contract.
func testB5_canonicalConstantsRoundTripThroughMake() {
let logger1 = TestWarningLogger()
let s1 = STTBackendSelector.make(
backend: STTBackendSelector.backendSpeechAnalyzer,
warningLogger: logger1
)
XCTAssertTrue(s1 is SpeechAnalyzerSTT)
XCTAssertNil(logger1.lastWarning,
"canonical 'speech_analyzer' constant must not log unknown-backend warning")
let logger2 = TestWarningLogger()
let s2 = STTBackendSelector.make(
backend: STTBackendSelector.backendWhisperKit,
warningLogger: logger2
)
XCTAssertTrue(s2 is WhisperKitSTT)
XCTAssertNil(logger2.lastWarning,
"canonical 'whisperkit' constant must not log unknown-backend warning")
// Anti-pattern: camelCase strings (the pre-fix `get_self_state`
// shape) must land in the default branch and emit a warning.
let logger3 = TestWarningLogger()
let s3 = STTBackendSelector.make(backend: "whisperKit", warningLogger: logger3)
XCTAssertTrue(s3 is SpeechAnalyzerSTT,
"camelCase 'whisperKit' must fall through to default")
XCTAssertNotNil(logger3.lastWarning,
"camelCase 'whisperKit' must produce an unknown-backend warning (lock against silent drift)")
}
}
// MARK: - Test doubles
@@ -107,6 +107,50 @@ final class TTSEngineActorTier1Tests: XCTestCase {
}
}
/// TET1-5 (Issue #30): natural completion emits `.ttsStopped` after
/// `.finished` so any downstream ducking consumer can release the
/// input duck. Before the fix only the barge-in path
/// (`InterruptSequence.run`) yielded `.ttsStopped`, so a normal
/// spoken reply would have left ducking engaged forever.
func test_TET1_5_naturalCompletion_emitsTTSStopped() async throws {
let engine = TTSEngineActor(
orpheus: nil,
tier1: AVSpeechSynth(),
fallback: nil
)
// Collector that does NOT short-circuit on `.finished` — it must
// see `.ttsStopped` after `.finished` and exit on `.ttsStopped`.
let stream = engine.ttsEventStream
let collector = Task.detached {
var got: [TTSEvent] = []
let deadline = Date().addingTimeInterval(4.0)
for await ev in stream {
got.append(ev)
if case .ttsStopped = ev { break }
if Date() > deadline { break }
}
return got
}
try await engine.synthesize("ok", tier: .tier1, voice: "en-US")
let collected = await collector.value
// Assert ordering: `.finished` must precede `.ttsStopped`.
let finishedIdx = collected.firstIndex(where: {
if case .finished = $0 { return true } else { return false }
})
let stoppedIdx = collected.firstIndex(where: {
if case .ttsStopped = $0 { return true } else { return false }
})
XCTAssertNotNil(finishedIdx, "expected .finished in event stream; got \(collected)")
XCTAssertNotNil(stoppedIdx,
"natural completion must emit .ttsStopped after .finished (audit-2026-05-12 P0-3 / Issue #30); got \(collected)")
if let f = finishedIdx, let s = stoppedIdx {
XCTAssertLessThan(f, s, ".finished must precede .ttsStopped")
}
}
/// TET1-4: rapid-fire tier-1 calls cancel prior in-flight synthesis.
/// The actor's serial executor + reentrancy guard handles this.
func test_TET1_4_rapidFire_cancelsPriorSynthesis() async throws {
@@ -236,4 +236,102 @@ final class VADGatedSessionTests: XCTestCase {
await controller.shutdown()
}
// MARK: - VAD-6 (Issue #34): startSTTSession resets cached VAD
/// Regression for audit-2026-05-12 P1-3 / Issue #34: production
/// `vadFactory` closure caches a single `SileroVAD` instance. Without
/// `reset()` on each new session, hidden state (specifically
/// `wasSpeech`) leaks across sessions. End of session N with
/// `.speechEnd` leaves `wasSpeech == false`; the first window of
/// session N+1 that crosses threshold then emits `.speechStart`
/// (correct only if reset happened). But end of session N in the
/// `.speech` state leaves `wasSpeech == true`, so session N+1's
/// first sub-threshold window emits `.speechEnd` and the second
/// emits `.silence` — soft-spoken opening syllables are misclassified.
///
/// Test strategy: drive a session that ends with `wasSpeech == true`
/// (the controller calls `shutdown` mid-speech, then we restart and
/// PTT-down again). On the second session, the first chunk's prob is
/// SILENCE — pre-fix the VAD would emit `.speechEnd` (carrying over
/// from session 1's `.speech`); post-fix it emits `.silence` (clean
/// state via `reset()`).
///
/// We observe the effect indirectly via the orchestrator submit
/// behaviour: session 2 fed a long silence run after one short
/// silence chunk should NOT submit (no speech ever started). Pre-fix
/// the `.speechEnd → silence` run would arm a finalize that submits
/// the empty STT result. Post-fix, the session stays in pre-speech
/// state and no submit lands.
func testVAD6_cachedVADResetBetweenSessions() async throws {
// Single SileroVAD instance reused across sessions — mirrors
// AppDelegate's `vadFactory: { sileroVAD }` production wiring.
let mockEngine = MockVADEngine(probabilities: [])
let cachedVAD = SileroVAD(engine: mockEngine)
let stt = CapturingSTTProvider(finalResult: "should not submit")
let orchestrator = MockOrchestratorForController()
let tts = MockTTSForController()
let banner = MockBannerForController()
let bus = MockBusEmitter()
let (_, hudCont) = AsyncStream<VoiceHudIntent>.makeStream()
let (wwStream, _) = AsyncStream<WakeWordEvent>.makeStream()
// Session 1: speech run that leaves `wasSpeech == true` at the
// SileroVAD level. We feed 3 speech chunks, then shut down
// mid-speech.
mockEngine.setProbabilities([0.9, 0.9, 0.9])
let controller1 = VoiceController(
wakeWordStream: wwStream,
vadFactory: { cachedVAD },
sttFactory: { stt },
tts: tts,
orchestrator: orchestrator,
bannerCoordinator: banner,
bus: bus,
voiceHudCont: hudCont,
chunkPump: makePump(chunkCount: 3)
)
await controller1.startForTests()
await controller1.pttDown()
try await Task.sleep(for: .milliseconds(80))
// PttUp triggers `endSTTSession` — cleanly exits session 1.
await controller1.pttUp()
try await Task.sleep(for: .milliseconds(80))
await controller1.shutdown()
// Snapshot any submits from session 1.
let session1Submits = await orchestrator.submitCallTexts.count
// Session 2: feed pure silence (one chunk). Pre-fix, the cached
// VAD's `wasSpeech == true` would mean the first silence chunk
// emits `.speechEnd`, arming the finalize. Post-fix, `reset()`
// clears `wasSpeech` before the session starts, so the first
// silence chunk emits `.silence` (no finalize armed).
mockEngine.setProbabilities([0.1])
let controller2 = VoiceController(
wakeWordStream: wwStream,
vadFactory: { cachedVAD },
sttFactory: { stt },
tts: tts,
orchestrator: orchestrator,
bannerCoordinator: banner,
bus: bus,
voiceHudCont: hudCont,
chunkPump: makePump(chunkCount: 1)
)
await controller2.startForTests()
await controller2.pttDown()
try await Task.sleep(for: .milliseconds(120))
// Don't pttUp — let VAD finalize OR not based on its own decision.
try await Task.sleep(for: .milliseconds(120))
let session2Submits = await orchestrator.submitCallTexts.count - session1Submits
XCTAssertEqual(
session2Submits, 0,
"Session 2 must not auto-submit when the only chunk is silence — pre-fix the cached VAD's wasSpeech=true would have emitted .speechEnd and armed a stale finalize."
)
await controller2.shutdown()
}
}
@@ -116,6 +116,95 @@ final class WakeWordDAGTests: XCTestCase {
}
}
/// WD-3 (Issue #28): the DAG survives an audio-graph rebuild —
/// `stopFeed()` keeps the public stream alive, and `start(ring:)`
/// against a new ring resumes inference into the same stream. This
/// is the canonical "rebuild boundary" sequence AppDelegate now
/// wires (cancelInFlight → stopFeed; rebuildStream consumer →
/// start(ring: newRing)).
///
/// Regression: prior to the fix, `cancelInFlight` called `cancel()`
/// which finished the stream — wake-word died on first
/// device-change / mic-regrant / ring-overflow event.
func test_DAG_survivesRebuild_streamStaysLive_newRingDrives() async throws {
let recorder = InputRecorder()
let session = OpenWakeWordSession(scriptedClassifier: { input in
Task { await recorder.record(input) }
return 0.0
})
// Initial ring + DAG arming.
let ring1 = RingBuffer(capacityFrames: 16000)
let markerA: [Float] = Array(repeating: 0.11, count: 1280)
writeSamples(markerA, into: ring1)
let dag = WakeWordDAG(session: session)
await dag.start(ring: ring1)
// Wait for at least one record from ring1.
let deadline1 = Date().addingTimeInterval(1.0)
while Date() < deadline1 {
if await recorder.count > 0 { break }
try? await Task.sleep(for: .milliseconds(20))
}
let preRebuildCount = await recorder.count
XCTAssertGreaterThan(
preRebuildCount, 0,
"Pre-rebuild: DAG must drive the session from ring1"
)
// Spawn a consumer that exits only when the stream is finished —
// mirrors the production `VoiceController.spawnWakeWordConsumer`.
// We assert it stays running across the rebuild boundary.
actor ConsumerFlag { var exited = false; func set() { exited = true } }
let flag = ConsumerFlag()
let consumerTask = Task {
for await _ in dag.wakeWordStream { /* ignore events */ }
await flag.set()
}
// Simulate AudioGraphOwner.teardown → cancelInFlight.
// The bug fix: stopFeed() (NOT cancel()) so the public stream
// survives. The consumer Task must NOT exit.
await dag.stopFeed()
try? await Task.sleep(for: .milliseconds(100))
let exitedAfterStopFeed = await flag.exited
XCTAssertFalse(
exitedAfterStopFeed,
"stopFeed() must NOT finish the wake-word stream — the bug was cancel() finishing it"
)
// Re-arm against a NEW ring (simulating the new ring buffer from
// AudioGraphOwner's post-rebuild graph).
let priorCount = await recorder.count
let ring2 = RingBuffer(capacityFrames: 16000)
let markerB: [Float] = Array(repeating: 0.99, count: 1280)
writeSamples(markerB, into: ring2)
await dag.start(ring: ring2)
// Wait for new ring's marker to drive the session.
let deadline2 = Date().addingTimeInterval(1.0)
var sawNewMarker = false
while Date() < deadline2 {
let snap = await recorder.snapshot
if snap.suffix(snap.count - priorCount).contains(where: { buf in
abs((buf.first ?? .nan) - 0.99) < 1e-5
}) {
sawNewMarker = true
break
}
try? await Task.sleep(for: .milliseconds(20))
}
await dag.cancel()
consumerTask.cancel()
XCTAssertTrue(
sawNewMarker,
"After stopFeed() + start(ring: newRing), the DAG must drive the session from the new ring (rebuild boundary contract)"
)
}
/// WD-2: when the ring is empty, the DAG should NOT call the
/// classifier (current loop sleeps and retries when framesRead == 0).
/// Locks in the contract that we don't burn CPU calling the