Files
Jarvis/App/Voice/VoiceTTSAdapter.swift
james.maes 4b1dd6404b fix(voice): v0.1 CRIT cluster — wake / STT / TTS / boot probes (#28, #29, #30, #31, #32, #33, #34, #36, #158)
#28 (CRIT): setCancelInFlight calls stopFeed() (preserves stream); rebuild consumer re-arms DAG against new ring. Wake-word no longer dies on audio-graph rebuild.

#29 (CRIT): sttFactory reads PerTurnSnapshot.stt.whisperKitFallback; get_self_state returns canonical snake_case backend constants. Factory signature: () -> any STTProvider → () async -> any STTProvider (existing test sites compile unchanged under Swift 6 implicit-async-closure rules).

#30 (CRIT): TTSEngineActor.synthesize emits .ttsStopped after .finished on natural completion (two-emit-site model: barge-in + natural-completion).

#31 (CRIT): VoiceTTSAdapter.tierResolver wired to read snap.tts.tier from configStore.

#32 (HIGH): Rolling-window trim in OpenWakeWordSession.runORT — mel ≤ 84, embeddings ≤ 16, nextEmbStart slides on drop.

#33 (HIGH): three new boot probes — WakeWordBootHealthProbe, STTBootHealthProbe, TTSBootHealthProbe. New WakeWordDAG.isFeedArmed + VoiceTTSAdapter.hasEngine accessors.

#34 (HIGH): vad.reset() at top of startSTTSession clears Silero VAD LSTM hidden state + wasSpeech between sessions.

#36 (HIGH): new Broadcaster.Priority.agentHud (protects stateChange/turnEnd/error); installAgent adds subscriber pumping AgentHudIntent into dormantAgentContinuation.

Voice 90 + AgentCore 242 tests pass; all 18 boundary gates green.
2026-05-13 06:31:17 -05:00

74 lines
2.9 KiB
Swift

import Foundation
import Voice // VoiceTTSInterface, TTSEngineActor, TTSTier
/// Production adapter bridging `Voice.VoiceTTSInterface` to `TTSEngineActor`.
///
/// Phase 9 / Plan 4 / D-09: replaces `NullTTSAdapter`.
///
/// **Engine optional.** Plan 4 wires the adapter regardless of whether a
/// real `TTSEngineActor` has been constructed by `installVoice`. When
/// `engine == nil` the adapter no-ops gracefully — preserves the current
/// production behaviour (TTSEngineActor construction is gated on Orpheus
/// MLX weights + AVSpeech availability and lands in a follow-on plan) while
/// the rest of the orchestrator wiring goes live.
///
/// **Tier resolver.** The TTS tier (tier1 AVSpeech vs tier2 Orpheus) is
/// resolved per-synthesis from `PerTurnSnapshot.tts.tier` so a config
/// change picks up on the next call without restarting voice. Defaults to
/// `.tier1` when no resolver is supplied.
///
/// **Voice id.** Defaulted to `"en-US"` for tier-1 (AVSpeech accepts a
/// language code via `AVSpeechSynthesisVoice(language:)`) and `"tara"` for
/// tier-2 (Orpheus voice character per RESEARCH-DELTAS). The plan-level
/// research notes that Orpheus streaming is confirmed; voice character is
/// fixed for now and revisited when TTS configurability lands.
actor VoiceTTSAdapter: VoiceTTSInterface {
private let engine: TTSEngineActor?
private let tierResolver: @Sendable () async -> TTSTier
init(
engine: TTSEngineActor?,
tierResolver: @escaping @Sendable () async -> TTSTier = { .tier1 }
) {
self.engine = engine
self.tierResolver = tierResolver
}
var hasSynthInFlight: Bool {
get async {
guard let engine else { return false }
return await engine.hasSynthInFlight
}
}
/// 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()
let voiceId = voiceIDForTier(tier)
// TTSEngineActor.synthesize throws on cancellation / synthesis
// failure. We swallow these here because Voice's protocol is
// throwing-free; the underlying error is already logged through
// TTSEvent and JarvisLogChannel.tts.
try? await engine.synthesize(text, tier: tier, voice: voiceId)
}
func cancelTTS() async {
guard let engine else { return }
await engine.cancel()
}
private nonisolated func voiceIDForTier(_ tier: TTSTier) -> String {
switch tier {
case .tier1:
return "en-US" // AVSpeech language code
case .tier2:
return "tara" // Orpheus voice character (RESEARCH-DELTAS)
}
}
}