fix(voice): v0.1 CRIT cluster — wake / STT / TTS / boot probes #158
No files matched your search
+165
-7
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
|
||||
Reference in new issue
Block a user