Files
clarity-helper/src/lib/audio/AudioEngine.svelte.ts
T
jpremill 1feea40684 feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice
- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer
- Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support
- Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline
- Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots)
- Migrate core application to Svelte 5 runes (, , ) with reactive stores
- Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
2026-09-29 23:35:05 -04:00

456 lines
15 KiB
TypeScript

import type { Scenario, Message, AnalysisResult } from '../../../types';
import { UserSettingsStore } from '../stores/UserSettingsStore.svelte';
import {
streamOpenRouterChat,
analyzeTurnWithOpenRouter,
generateSessionSummaryReport,
synthesizeSpeechWithOpenRouter
} from '../../../services/openRouterService';
import { synthesizeSpeechWithGemini } from '../../../services/geminiVoiceService';
import { getOpenRouterApiKey, getGeminiApiKey } from '../../../services/apiConfig';
import { saveSession } from '../../../services/sessionStorage';
import { VOICE_PROFILES } from '../../../constants';
export type SessionStatus = 'idle' | 'connecting' | 'active' | 'analyzing' | 'finished' | 'error';
export class AudioEngine {
status = $state<SessionStatus>('idle');
errorMessage = $state<string | null>(null);
isMuted = $state(false);
micLevel = $state(0);
aiAudioLevel = $state(0);
isAiSpeaking = $state(false);
isUserSpeaking = $state(false);
messages = $state<Message[]>([]);
liveInsight = $state<AnalysisResult | null>(null);
isAnalyzingTurn = $state(false);
finalReport = $state<AnalysisResult | null>(null);
// Derived unified visualizer level
activeLevel = $derived(this.isAiSpeaking ? this.aiAudioLevel : this.micLevel);
private inputCtx: AudioContext | null = null;
private outputCtx: AudioContext | null = null;
private userAnalyser: AnalyserNode | null = null;
private aiAnalyser: AnalyserNode | null = null;
private currentAudioSource: AudioBufferSourceNode | null = null;
private stream: MediaStream | null = null;
private recognition: any = null;
private animFrameId: number | null = null;
private isProcessingTurn = false;
constructor(
private scenario: Scenario,
private userSettings: UserSettingsStore
) {}
async start() {
const apiKey = getOpenRouterApiKey(this.userSettings.openRouterApiKey);
if (!apiKey) {
this.status = 'error';
this.errorMessage = 'OpenRouter API Key is missing. Please set your API key in Settings.';
return;
}
try {
this.status = 'connecting';
this.errorMessage = null;
// WebAudio Context Initialization
const AudioCtxClass = typeof window !== 'undefined'
? (window.AudioContext || (window as any).webkitAudioContext)
: (globalThis as any).AudioContext;
if (!AudioCtxClass) {
throw new Error('WebAudio AudioContext is not supported in this environment.');
}
this.inputCtx = new AudioCtxClass();
this.outputCtx = new AudioCtxClass();
this.userAnalyser = this.inputCtx.createAnalyser();
this.userAnalyser.fftSize = 256;
this.aiAnalyser = this.outputCtx.createAnalyser();
this.aiAnalyser.fftSize = 256;
// Microphone Stream
const constraints: MediaStreamConstraints = {
audio: this.userSettings.preferredAudioDeviceId
? { deviceId: { exact: this.userSettings.preferredAudioDeviceId } }
: true
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
const micSource = this.inputCtx.createMediaStreamSource(this.stream);
micSource.connect(this.userAnalyser);
// Web Speech Recognition
const SpeechRecognition = typeof window !== 'undefined'
? ((window as any).SpeechRecognition || (window as any).webkitSpeechRecognition)
: (globalThis as any).SpeechRecognition;
if (SpeechRecognition) {
this.recognition = new SpeechRecognition();
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.recognition.lang = 'en-US';
let speechDebounceTimer: any = null;
let pendingUserSpeech = '';
this.recognition.onresult = (event: any) => {
if (this.isMuted || this.isAiSpeaking) return;
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
pendingUserSpeech += ' ' + transcript;
} else {
interimTranscript += transcript;
}
}
if (interimTranscript.trim() || pendingUserSpeech.trim()) {
this.isUserSpeaking = true;
}
clearTimeout(speechDebounceTimer);
speechDebounceTimer = setTimeout(() => {
const finalSpeech = (pendingUserSpeech + ' ' + interimTranscript).trim();
if (finalSpeech.length > 2) {
this.isUserSpeaking = false;
this.handleUserTurn(finalSpeech);
pendingUserSpeech = '';
}
}, 1200);
};
this.recognition.onerror = (e: any) => {
if (e.error !== 'no-speech') {
console.warn('Speech recognition warning:', e.error);
}
};
this.recognition.onend = () => {
if (this.status === 'active' && !this.isMuted) {
try { this.recognition?.start(); } catch (err) {}
}
};
try {
this.recognition.start();
} catch (e) {
console.warn('Speech recognition auto-start prevented', e);
}
}
// Initial Message
const initialGreeting: Message = {
id: 'init',
role: 'model',
text: this.scenario.initialMessage,
timestamp: new Date()
};
this.messages = [initialGreeting];
this.status = 'active';
// Start Frequency Analysis Animation Loop
this.startMeterLoop();
// Speak initial greeting
await this.speakText(this.scenario.initialMessage);
} catch (err: any) {
console.error('Audio initialization failure', err);
this.status = 'error';
this.errorMessage = err?.message || 'Failed to initialize audio input device.';
}
}
private startMeterLoop() {
const userBuffer = new Uint8Array(this.userAnalyser ? this.userAnalyser.frequencyBinCount : 0);
const aiBuffer = new Uint8Array(this.aiAnalyser ? this.aiAnalyser.frequencyBinCount : 0);
const updateMeter = () => {
// 1. Microphone Level
if (this.userAnalyser && !this.isMuted && !this.isAiSpeaking) {
this.userAnalyser.getByteFrequencyData(userBuffer);
let sum = 0;
for (let i = 0; i < userBuffer.length; i++) sum += userBuffer[i];
const avg = sum / userBuffer.length;
this.micLevel = Math.min(100, Math.round((avg / 128) * 100));
} else {
this.micLevel = 0;
}
// 2. AI Audio Level
if (this.aiAnalyser && this.isAiSpeaking) {
this.aiAnalyser.getByteFrequencyData(aiBuffer);
let sum = 0;
for (let i = 0; i < aiBuffer.length; i++) sum += aiBuffer[i];
const avg = sum / aiBuffer.length;
this.aiAudioLevel = Math.min(100, Math.round((avg / 128) * 100));
} else {
this.aiAudioLevel = 0;
}
this.animFrameId = requestAnimationFrame(updateMeter);
};
this.animFrameId = requestAnimationFrame(updateMeter);
}
toggleMute() {
this.isMuted = !this.isMuted;
if (this.isMuted) {
this.stopAllAudio();
try { this.recognition?.stop(); } catch (e) {}
} else {
try { this.recognition?.start(); } catch (e) {}
}
}
async speakText(text: string) {
this.stopAllAudio();
this.isAiSpeaking = true;
const profile = VOICE_PROFILES.find(
(p) => p.id.toLowerCase() === this.userSettings.voiceName.toLowerCase()
) || VOICE_PROFILES[0];
// 1. Google Gemini Native Voice (gemini-3.8-flash-tts with Fenrir, Puck, Kore, etc.)
const geminiKey = getGeminiApiKey(this.userSettings.geminiApiKey);
if (geminiKey) {
try {
const arrayBuffer = await synthesizeSpeechWithGemini(
text,
profile.id,
geminiKey
);
if (!this.outputCtx) return;
if (this.outputCtx.state === 'suspended') await this.outputCtx.resume();
const buffer = await this.outputCtx.decodeAudioData(arrayBuffer);
const source = this.outputCtx.createBufferSource();
source.buffer = buffer;
if (this.aiAnalyser) {
source.connect(this.aiAnalyser);
this.aiAnalyser.connect(this.outputCtx.destination);
} else {
source.connect(this.outputCtx.destination);
}
source.onended = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
this.currentAudioSource = source;
source.start(0);
return;
} catch (e) {
console.warn('Gemini 3.8 native voice synthesis fallback to OpenRouter/WebSpeech:', e);
}
}
// 2. OpenRouter TTS Fallback
try {
const arrayBuffer = await synthesizeSpeechWithOpenRouter(
text,
profile.openRouterVoice,
this.userSettings.openRouterApiKey
);
if (!this.outputCtx) return;
if (this.outputCtx.state === 'suspended') await this.outputCtx.resume();
const buffer = await this.outputCtx.decodeAudioData(arrayBuffer);
const source = this.outputCtx.createBufferSource();
source.buffer = buffer;
if (this.aiAnalyser) {
source.connect(this.aiAnalyser);
this.aiAnalyser.connect(this.outputCtx.destination);
} else {
source.connect(this.outputCtx.destination);
}
source.onended = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
this.currentAudioSource = source;
source.start(0);
return;
} catch (e) {
console.warn('OpenRouter TTS fallback to Web Speech Synthesis', e);
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = profile.webSpeechTuning.rate;
utterance.pitch = profile.webSpeechTuning.pitch;
utterance.onstart = () => { this.isAiSpeaking = true; };
utterance.onend = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
utterance.onerror = () => {
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
};
window.speechSynthesis.speak(utterance);
} else {
this.isAiSpeaking = false;
}
}
}
async handleUserTurn(userText: string) {
if (this.isProcessingTurn || !userText.trim()) return;
this.isProcessingTurn = true;
this.stopAllAudio();
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
text: userText.trim(),
timestamp: new Date()
};
const currentHistory = [...this.messages, userMsg];
this.messages = currentHistory;
const botMsgId = (Date.now() + 1).toString();
const botMsg: Message = {
id: botMsgId,
role: 'model',
text: '',
timestamp: new Date()
};
this.messages = [...currentHistory, botMsg];
const roleplaySystemInstruction = `
${this.scenario.systemInstruction}
[CONTEXT]
You are participating in a clinical CBT roleplay simulation.
The instructions above define your character, the scenario, and your internal CBT logic.
[TASK]
Respond to the user naturally in character to advance the roleplay scenario.
[CONSTRAINTS]
- CRITICAL: Stay strictly in character as the persona defined in the scenario.
- CRITICAL: NEVER provide clinical feedback, advice, or meta-commentary about the user's progress.
- CRITICAL: Keep dialogue natural and conversational (1-3 sentences per turn).
- If the user successfully concludes the scenario, end the dialogue naturally.
`;
try {
let accumulatedText = '';
await streamOpenRouterChat(
currentHistory,
roleplaySystemInstruction,
{
onChunk: (chunk) => {
accumulatedText += chunk;
this.messages = this.messages.map((m) =>
m.id === botMsgId ? { ...m, text: accumulatedText } : m
);
},
onComplete: (fullText) => {
this.speakText(fullText);
// Parallel out-of-band turn supervisor
this.isAnalyzingTurn = true;
const turnContext = `User said: "${userMsg.text}". Roleplay partner said: "${fullText}".`;
analyzeTurnWithOpenRouter(
turnContext,
this.scenario.title,
this.scenario.description,
this.scenario.cbtFocus,
{
apiKey: this.userSettings.openRouterApiKey,
model: this.userSettings.supervisorModel
}
).then((res) => {
this.liveInsight = res;
}).catch((err) => {
console.error('Voice turn analysis error', err);
}).finally(() => {
this.isAnalyzingTurn = false;
this.isProcessingTurn = false;
});
},
onError: (err) => {
console.error('OpenRouter voice stream failed', err);
this.isProcessingTurn = false;
}
},
{
apiKey: this.userSettings.openRouterApiKey,
model: this.userSettings.roleplayModel
}
);
} catch (err) {
console.error('User voice turn execution failed', err);
this.isProcessingTurn = false;
}
}
async finishSession() {
this.status = 'analyzing';
this.stopAllAudio();
try {
const report = await generateSessionSummaryReport(
this.messages,
this.scenario.title,
this.scenario.description,
{
apiKey: this.userSettings.openRouterApiKey,
model: this.userSettings.supervisorModel
}
);
this.finalReport = report;
saveSession(this.scenario, 'voice', this.messages, report);
this.status = 'finished';
} catch (err) {
console.error('Post-session analysis error', err);
this.status = 'error';
this.errorMessage = 'Failed to generate session report.';
}
}
stopAllAudio() {
if (this.currentAudioSource) {
try { this.currentAudioSource.stop(); } catch (e) {}
this.currentAudioSource = null;
}
const synth = typeof window !== 'undefined' && 'speechSynthesis' in window
? window.speechSynthesis
: typeof (globalThis as any).speechSynthesis !== 'undefined'
? (globalThis as any).speechSynthesis
: null;
if (synth) {
synth.cancel();
}
this.isAiSpeaking = false;
this.aiAudioLevel = 0;
}
destroy() {
this.stopAllAudio();
if (this.animFrameId) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
try { this.recognition?.stop(); } catch (e) {}
this.stream?.getTracks().forEach((t) => t.stop());
this.inputCtx?.close();
this.outputCtx?.close();
}
}