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
This commit is contained in:
jpremill committed 2026-09-29 23:35:05 -04:00
1 parent c82b112624
commit 1feea40684
148 files changed
+11230 -1706

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
/**
* API Configuration & Key Resolution Service
*
* Provides unified, hierarchical access to OpenRouter API credentials,
* supporting browser LocalStorage overrides and environment variables.
*/
import { UserSettings } from '../types';
const SETTINGS_KEY = 'cognitive-clarity-settings';
export const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.8-flash';
export const DEFAULT_SUPERVISOR_MODEL = 'google/gemini-3.8-flash';
export function getStoredUserSettings(): Partial<UserSettings> {
try {
if (typeof localStorage === 'undefined') {
return {};
}
const saved = localStorage.getItem(SETTINGS_KEY);
return saved ? JSON.parse(saved) : {};
} catch (e) {
console.error("Failed to read user settings from localStorage", e);
return {};
}
}
/**
* Returns the effective OpenRouter API Key.
* Precedence:
* 1. Explicit argument
* 2. UserSettings in LocalStorage
* 3. Vite environment variable (VITE_OPENROUTER_API_KEY)
* 4. Node process environment variable (OPENROUTER_API_KEY)
*/
export function getOpenRouterApiKey(customKey?: string): string {
if (customKey !== undefined) {
return customKey.trim();
}
const stored = getStoredUserSettings();
if (stored.openRouterApiKey && stored.openRouterApiKey.trim().length > 0) {
return stored.openRouterApiKey.trim();
}
if (typeof process !== 'undefined' && process.env?.OPENROUTER_API_KEY !== undefined) {
return process.env.OPENROUTER_API_KEY.trim();
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_OPENROUTER_API_KEY) {
return (import.meta.env.VITE_OPENROUTER_API_KEY as string).trim();
}
return '';
}
/**
* Checks if a valid OpenRouter API Key is configured.
*/
export function hasOpenRouterApiKey(): boolean {
return getOpenRouterApiKey().length > 0;
}
/**
* Returns the effective Google Gemini API Key.
* Precedence:
* 1. Explicit argument
* 2. UserSettings in LocalStorage (geminiApiKey)
* 3. Node process environment variable (GEMINI_API_KEY / API_KEY / VITE_GEMINI_API_KEY)
* 4. Vite environment variable (VITE_GEMINI_API_KEY)
*/
export function getGeminiApiKey(customKey?: string): string {
if (customKey !== undefined) {
return customKey.trim();
}
const stored = getStoredUserSettings();
if (stored.geminiApiKey && stored.geminiApiKey.trim().length > 0) {
return stored.geminiApiKey.trim();
}
if (typeof process !== 'undefined') {
if (process.env?.GEMINI_API_KEY !== undefined) return process.env.GEMINI_API_KEY.trim();
if (process.env?.API_KEY !== undefined) return process.env.API_KEY.trim();
if (process.env?.VITE_GEMINI_API_KEY !== undefined) return process.env.VITE_GEMINI_API_KEY.trim();
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_GEMINI_API_KEY) {
return (import.meta.env.VITE_GEMINI_API_KEY as string).trim();
}
return '';
}
/**
* Checks if a valid Google Gemini API Key is configured.
*/
export function hasGeminiApiKey(): boolean {
return getGeminiApiKey().length > 0;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Google Gemini Native Voice Synthesis Service
*
* Provides direct speech synthesis using Google's native Gemini voice models
* (gemini-3.8-flash-tts and gemini-2.5-flash-preview-tts) with official prebuilt
* voices: Fenrir (Gruff / Salt of the Earth), Puck, Charon, Kore, Aoede.
*/
import { getGeminiApiKey } from './apiConfig';
import { decodeBase64 } from './audioUtils';
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta';
/**
* Packages raw PCM16 audio into a self-contained WAV ArrayBuffer.
*/
export function pcmToWav(pcmData: Uint8Array, sampleRate = 24000, numChannels = 1): ArrayBuffer {
const wavBuffer = new ArrayBuffer(44 + pcmData.byteLength);
const view = new DataView(wavBuffer);
// "RIFF"
view.setUint32(0, 0x52494646, false);
view.setUint32(4, 36 + pcmData.byteLength, true);
// "WAVE"
view.setUint32(8, 0x57415645, false);
// "fmt "
view.setUint32(12, 0x666d7420, false);
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // AudioFormat: PCM (1)
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true); // BitsPerSample: 16
// "data"
view.setUint32(36, 0x64617461, false);
view.setUint32(40, pcmData.byteLength, true);
new Uint8Array(wavBuffer, 44).set(pcmData);
return wavBuffer;
}
/**
* Synthesizes speech using Google Gemini's native voice model.
*
* @param text The text dialogue to speak.
* @param voiceName Prebuilt Gemini voice: 'Fenrir' | 'Puck' | 'Charon' | 'Kore' | 'Aoede' | 'Zephyr'
* @param apiKey Optional explicit Google Gemini API key.
* @returns ArrayBuffer containing standard decoded audio data (WAV container).
*/
export async function synthesizeSpeechWithGemini(
text: string,
voiceName = 'Fenrir',
apiKey?: string
): Promise<ArrayBuffer> {
const effectiveKey = getGeminiApiKey(apiKey);
if (!effectiveKey) {
throw new Error('Google Gemini API key is missing. Please set GEMINI_API_KEY in Settings or .env.');
}
// Normalize voice name to valid Gemini prebuilt voices (Zephyr -> Puck/Fenrir)
const validVoices = ['Fenrir', 'Puck', 'Charon', 'Kore', 'Aoede'];
const targetVoice = validVoices.includes(voiceName) ? voiceName : 'Fenrir';
const modelsToTry = [
'gemini-3.8-flash-tts',
'gemini-2.5-flash-preview-tts'
];
let lastError: any = null;
for (const model of modelsToTry) {
try {
const url = `${GEMINI_API_BASE}/models/${model}:generateContent?key=${encodeURIComponent(effectiveKey)}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [{ parts: [{ text }] }],
generationConfig: {
responseModalities: ['AUDIO'],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: targetVoice
}
}
}
}
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini ${model} TTS error (${response.status}): ${errorText}`);
}
const json = await response.json();
const part = json.candidates?.[0]?.content?.parts?.[0];
if (!part || !part.inlineData?.data) {
throw new Error(`Gemini ${model} returned no audio content in response candidates.`);
}
const mimeType = part.inlineData.mimeType || '';
const rawBytes = decodeBase64(part.inlineData.data);
if (mimeType.includes('wav')) {
const slice = rawBytes.buffer.slice(rawBytes.byteOffset, rawBytes.byteOffset + rawBytes.byteLength);
return slice as ArrayBuffer;
}
// If PCM (e.g. audio/L16;codec=pcm;rate=24000), wrap in WAV container for universal browser decoding
return pcmToWav(rawBytes, 24000, 1);
} catch (err: any) {
lastError = err;
console.warn(`Failed synthesis with Gemini model ${model}, checking next model...`, err);
}
}
throw lastError || new Error('All Gemini TTS models failed to generate audio.');
}
+370
View File
@@ -0,0 +1,370 @@
/**
* OpenRouter AI Service Provider
*
* Provides unified integration with OpenRouter API for streaming roleplay dialogue,
* real-time clinical supervision, scenario generation, and vocal speech synthesis/transcription.
*/
import { Message, Scenario, AnalysisResult } from '../types';
import { getOpenRouterApiKey, DEFAULT_OPENROUTER_MODEL, DEFAULT_SUPERVISOR_MODEL } from './apiConfig';
import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT } from '../constants';
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
export interface OpenRouterOptions {
model?: string;
temperature?: number;
maxTokens?: number;
apiKey?: string;
}
export interface StreamChatCallbacks {
onChunk: (chunkText: string) => void;
onComplete?: (fullText: string) => void;
onError?: (error: any) => void;
}
/**
* Streams roleplay chat completion from OpenRouter using Server-Sent Events (SSE).
*/
export async function streamOpenRouterChat(
messages: Message[],
systemInstruction: string,
callbacks: StreamChatCallbacks,
options: OpenRouterOptions = {}
): Promise<string> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
const error = new Error("OpenRouter API key is missing. Please set your OpenRouter API Key in Settings.");
callbacks.onError?.(error);
throw error;
}
const model = options.model || DEFAULT_OPENROUTER_MODEL;
const formattedMessages = [
{ role: 'system', content: systemInstruction },
...messages.map(m => ({
role: m.role === 'model' ? 'assistant' : m.role,
content: m.text
}))
];
try {
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: formattedMessages,
temperature: options.temperature ?? 0.7,
max_tokens: options.maxTokens ?? 1200,
stream: true
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter API error (${response.status}): ${errorText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Unable to read response stream.");
const decoder = new TextDecoder('utf-8');
let fullText = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
if (trimmed === 'data: [DONE]') continue;
if (trimmed.startsWith('data: ')) {
try {
const data = JSON.parse(trimmed.substring(6));
const delta = data.choices?.[0]?.delta?.content || '';
if (delta) {
fullText += delta;
callbacks.onChunk(delta);
}
} catch (e) {
// Partial JSON buffer ignored
}
}
}
}
callbacks.onComplete?.(fullText);
return fullText;
} catch (err) {
callbacks.onError?.(err);
throw err;
}
}
/**
* Executes a single-turn structured CBT turn analysis via OpenRouter JSON mode.
*/
export async function analyzeTurnWithOpenRouter(
turnContext: string,
scenarioTitle: string,
scenarioDescription: string,
cbtFocus: string[],
options: OpenRouterOptions = {}
): Promise<AnalysisResult> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
throw new Error("OpenRouter API key is missing. Please set your API key in Settings.");
}
const model = options.model || DEFAULT_SUPERVISOR_MODEL;
const systemPrompt = `${SYSTEM_ANALYSIS_PROMPT}
SCENARIO CONTEXT:
Title: ${scenarioTitle}
Description: ${scenarioDescription}
CBT Focus Areas: ${cbtFocus.join(', ')}
[OUTPUT REQUIREMENTS]
Return ONLY a valid JSON object matching this schema:
{
"analysis": {
"distortions": ["string"],
"isAdaptive": boolean,
"evidence": "string"
},
"rating": {
"score": number (1-5),
"reason": "string"
},
"suggestions": {
"alternativeThought": "string",
"behavioralStep": "string"
}
}`;
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: turnContext }
],
response_format: { type: 'json_object' },
temperature: 0.2
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter Clinical Analysis failed (${response.status}): ${errorText}`);
}
const data = await response.json();
const rawContent = data.choices?.[0]?.message?.content || '{}';
return JSON.parse(rawContent) as AnalysisResult;
}
/**
* Generates a comprehensive post-session summary report via OpenRouter JSON mode.
*/
export async function generateSessionSummaryReport(
messages: Message[],
scenarioTitle: string,
scenarioDescription: string,
options: OpenRouterOptions = {}
): Promise<AnalysisResult> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
throw new Error("OpenRouter API key is missing.");
}
const model = options.model || DEFAULT_SUPERVISOR_MODEL;
const transcriptText = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n');
const systemPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
SCENARIO CONTEXT: ${scenarioTitle} - ${scenarioDescription}
[OUTPUT REQUIREMENTS]
Return ONLY a valid JSON object conforming exactly to this schema:
{
"analysis": {
"distortions": ["string"],
"isAdaptive": boolean,
"evidence": "string"
},
"rating": {
"score": number (1-5),
"reason": "string"
},
"suggestions": {
"alternativeThought": "string",
"behavioralStep": "string"
}
}`;
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `SESSION TRANSCRIPT:\n${transcriptText}` }
],
response_format: { type: 'json_object' },
temperature: 0.2
})
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`OpenRouter summary analysis failed (${response.status}): ${errText}`);
}
const data = await response.json();
const rawContent = data.choices?.[0]?.message?.content || '{}';
return JSON.parse(rawContent) as AnalysisResult;
}
/**
* Generates a new validated CBT roleplay scenario via OpenRouter JSON mode.
*/
export async function generateScenarioWithOpenRouter(
prompt: string,
selectedConcepts: string[],
difficulty: Scenario['difficulty'],
options: OpenRouterOptions = {}
): Promise<Scenario> {
const apiKey = getOpenRouterApiKey(options.apiKey);
if (!apiKey) {
throw new Error("OpenRouter API key is missing. Please enter your key in Settings.");
}
const model = options.model || DEFAULT_OPENROUTER_MODEL;
const selectedContext = selectedConcepts.length > 0
? `Focus specifically on these CBT concepts: ${selectedConcepts.join(', ')}.`
: '';
const systemInstruction = `
[CONTEXT]
You are a Clinical CBT Scenario Architect.
The user requires a structured clinical simulation designed to address specific cognitive distortions and behavioral challenges.
Clinical Focus: ${selectedContext}
[TASK]
Transform the user's prompt into a cohesive, structured clinical scenario that sets up a targeted roleplay environment.
[CONSTRAINTS]
- CATEGORIZATION: You MUST categorize the scenario into one of: 'Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk'.
- DIFFICULTY CALIBRATION: The requested difficulty is '${difficulty}'.
- 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative.
- 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs.
- 'Advanced': High emotional triggers, persistent clinical resistance, complex cognitive patterns.
- INTERNAL LOGIC: Provide a detailed "INTERNAL CBT LOGIC" section instructing the AI character how to react to the user's progress or cognitive lapses during the future roleplay.
- CHARACTER VOICE: Define a realistic, emotionally consistent voice profile.
[OUTPUT REQUIREMENTS]
Return ONLY a valid JSON object matching this schema:
{
"id": "string",
"title": "string",
"characterName": "string",
"description": "string",
"category": "Social Anxiety" | "Workplace" | "Relationships" | "Self-Talk",
"difficulty": "Beginner" | "Intermediate" | "Advanced",
"systemInstruction": "string",
"initialMessage": "string",
"cbtFocus": ["string"]
}
`;
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemInstruction },
{ role: 'user', content: `Generate a clinical scenario for: ${prompt || 'A scenario exploring the chosen CBT concepts.'}` }
],
response_format: { type: 'json_object' },
temperature: 0.7
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter scenario generation failed (${response.status}): ${errorText}`);
}
const data = await response.json();
const rawContent = data.choices?.[0]?.message?.content || '{}';
return JSON.parse(rawContent) as Scenario;
}
/**
* Synthesizes spoken audio from text using OpenRouter's speech endpoint.
*/
export async function synthesizeSpeechWithOpenRouter(
text: string,
voice = 'alloy',
apiKey?: string
): Promise<ArrayBuffer> {
const effectiveKey = getOpenRouterApiKey(apiKey);
if (!effectiveKey) {
throw new Error("OpenRouter API key is missing.");
}
const response = await fetch(`${OPENROUTER_BASE_URL}/audio/speech`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${effectiveKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://cognitive-clarity.app',
'X-Title': 'Cognitive Clarity'
},
body: JSON.stringify({
model: 'openai/tts-1',
input: text,
voice: voice
})
});
if (!response.ok) {
throw new Error(`OpenRouter TTS synthesis failed with status ${response.status}`);
}
return await response.arrayBuffer();
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Session Storage & Export Service
*
* Manages persistent storage of completed CBT roleplay sessions,
* enabling history review, markdown report exports, and JSON backups.
*/
import { SavedSession, Scenario, SessionMode, Message, AnalysisResult } from '../types';
const SESSIONS_STORAGE_KEY = 'cognitive-clarity-saved-sessions';
/**
* Retrieves all saved sessions from LocalStorage.
*/
export function getSavedSessions(): SavedSession[] {
try {
const raw = localStorage.getItem(SESSIONS_STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
console.error("Failed to load saved sessions", e);
return [];
}
}
/**
* Saves a completed session to LocalStorage.
*/
export function saveSession(
scenario: Scenario,
mode: SessionMode,
messages: Message[],
finalReport?: AnalysisResult
): SavedSession {
const newSession: SavedSession = {
id: `session-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
scenarioId: scenario.id,
scenarioTitle: scenario.title,
category: scenario.category,
difficulty: scenario.difficulty,
timestamp: new Date().toISOString(),
mode,
messages,
finalReport
};
try {
const existing = getSavedSessions();
const updated = [newSession, ...existing].slice(0, 50); // Keep last 50 sessions
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(updated));
} catch (e) {
console.error("Failed to save session", e);
}
return newSession;
}
/**
* Deletes a session by ID.
*/
export function deleteSession(sessionId: string): void {
try {
const existing = getSavedSessions();
const filtered = existing.filter(s => s.id !== sessionId);
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(filtered));
} catch (e) {
console.error("Failed to delete session", e);
}
}
/**
* Generates an Apple HIG-styled Markdown summary of a CBT session report.
*/
export function formatSessionAsMarkdown(
scenarioTitle: string,
analysis: AnalysisResult,
messages?: Message[]
): string {
const dateStr = new Date().toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
let md = `# Cognitive Clarity — Clinical CBT Session Report\n\n`;
md += `**Scenario:** ${scenarioTitle}\n`;
md += `**Date:** ${dateStr}\n`;
md += `**Clinical Evaluation:** ${analysis.analysis.isAdaptive ? '✅ Adaptive Growth Observed' : '⚠️ Clinical Patterns / Distortions Detected'}\n`;
md += `**Mastery Rating:** ${analysis.rating.score} / 5\n\n`;
md += `---\n\n`;
md += `## 1. Clinical Analysis\n\n`;
if (analysis.analysis.distortions.length > 0) {
md += `**Cognitive Distortions Identified:**\n`;
analysis.analysis.distortions.forEach(d => {
md += `- ${d}\n`;
});
md += `\n`;
}
md += `**Behavioral Evidence:**\n> "${analysis.analysis.evidence}"\n\n`;
md += `**Clinical Justification:**\n${analysis.rating.reason}\n\n`;
md += `## 2. Cognitive Reframing & Tactical Steps\n\n`;
md += `**Master Reframe:**\n> "${analysis.suggestions.alternativeThought}"\n\n`;
md += `**Homework & Behavioral Step:**\n${analysis.suggestions.behavioralStep}\n\n`;
if (messages && messages.length > 0) {
md += `---\n\n## 3. Session Transcript\n\n`;
messages.forEach(m => {
const sender = m.role === 'user' ? 'You' : 'AI Partner';
md += `**[${sender}]**: ${m.text}\n\n`;
});
}
return md;
}
/**
* Triggers a client-side file download of text content.
*/
export function downloadFile(content: string, fileName: string, contentType: string): void {
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+186
View File
@@ -0,0 +1,186 @@
import { synthesizeSpeechWithOpenRouter } from './openRouterService';
import { synthesizeSpeechWithGemini } from './geminiVoiceService';
import { getGeminiApiKey, hasGeminiApiKey, hasOpenRouterApiKey } from './apiConfig';
import { VOICE_PROFILES, type VoiceProfile } from '../constants';
import type { UserSettings } from '../types';
export interface VoicePlaybackState {
isPlaying: boolean;
activeVoiceId: UserSettings['voiceName'] | null;
mode: 'gemini' | 'openrouter' | 'webspeech' | 'idle';
error?: string;
}
export function getActiveVoiceEngine(): 'gemini' | 'openrouter' | 'webspeech' {
if (hasGeminiApiKey()) return 'gemini';
if (hasOpenRouterApiKey()) return 'openrouter';
return 'webspeech';
}
export class VoiceAuditionService {
private currentAudio: HTMLAudioElement | null = null;
private currentAudioUrl: string | null = null;
private activeUtterance: SpeechSynthesisUtterance | null = null;
/**
* Returns the VoiceProfile configuration for a given voice name.
*/
getProfile(voiceId: UserSettings['voiceName']): VoiceProfile {
return VOICE_PROFILES.find((p) => p.id === voiceId) || VOICE_PROFILES[0];
}
/**
* Auditions/plays a voice sample:
* 1. Google Gemini native voice (gemini-3.8-flash-tts) if GEMINI_API_KEY is available.
* 2. OpenRouter TTS if OPENROUTER_API_KEY is available.
* 3. Browser Web Speech Synthesis fallback.
*/
async playPreview(
voiceId: UserSettings['voiceName'],
customText?: string,
apiKey?: string,
onEnded?: () => void,
geminiApiKey?: string
): Promise<void> {
this.stop();
const profile = this.getProfile(voiceId);
const textToSpeak = customText?.trim() || profile.sampleText;
// 1. Prioritize Google Gemini Native Voice (gemini-3.8-flash-tts)
const geminiKey = getGeminiApiKey(geminiApiKey);
if (geminiKey) {
try {
const arrayBuffer = await synthesizeSpeechWithGemini(
textToSpeak,
profile.id,
geminiKey
);
const blob = new Blob([arrayBuffer], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
this.currentAudioUrl = url;
const audio = new Audio(url);
this.currentAudio = audio;
audio.onended = () => {
this.cleanupAudio();
onEnded?.();
};
audio.onerror = () => {
this.cleanupAudio();
this.playWebSpeech(textToSpeak, profile, onEnded);
};
await audio.play();
return;
} catch (err) {
console.warn('Gemini 3.8 native voice synthesis preview failed, checking OpenRouter fallback:', err);
}
}
// 2. OpenRouter TTS Fallback
if (apiKey && apiKey.trim().length > 0) {
try {
const arrayBuffer = await synthesizeSpeechWithOpenRouter(
textToSpeak,
profile.openRouterVoice,
apiKey
);
const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
this.currentAudioUrl = url;
const audio = new Audio(url);
this.currentAudio = audio;
audio.onended = () => {
this.cleanupAudio();
onEnded?.();
};
audio.onerror = () => {
this.cleanupAudio();
this.playWebSpeech(textToSpeak, profile, onEnded);
};
await audio.play();
return;
} catch (err) {
console.warn('OpenRouter TTS preview failed, falling back to Web Speech Synthesis:', err);
}
}
// 3. Web Speech API fallback with calibrated pitch/rate tuning
this.playWebSpeech(textToSpeak, profile, onEnded);
}
private playWebSpeech(
text: string,
profile: VoiceProfile,
onEnded?: () => void
): void {
const synth = typeof window !== 'undefined' && 'speechSynthesis' in window
? window.speechSynthesis
: (typeof (globalThis as any).speechSynthesis !== 'undefined' ? (globalThis as any).speechSynthesis : null);
const Utterance = typeof window !== 'undefined' && 'SpeechSynthesisUtterance' in window
? window.SpeechSynthesisUtterance
: (typeof (globalThis as any).SpeechSynthesisUtterance !== 'undefined' ? (globalThis as any).SpeechSynthesisUtterance : null);
if (synth && Utterance) {
synth.cancel();
const utterance = new Utterance(text);
utterance.pitch = profile.webSpeechTuning.pitch;
utterance.rate = profile.webSpeechTuning.rate;
utterance.onend = () => {
this.activeUtterance = null;
onEnded?.();
};
utterance.onerror = () => {
this.activeUtterance = null;
onEnded?.();
};
this.activeUtterance = utterance;
synth.speak(utterance);
} else {
onEnded?.();
}
}
private cleanupAudio(): void {
if (this.currentAudioUrl) {
URL.revokeObjectURL(this.currentAudioUrl);
this.currentAudioUrl = null;
}
this.currentAudio = null;
}
/**
* Immediately stops any active audition preview audio.
*/
stop(): void {
if (this.currentAudio) {
try {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
} catch (e) {}
this.cleanupAudio();
}
const synth = typeof window !== 'undefined' && 'speechSynthesis' in window
? window.speechSynthesis
: (typeof (globalThis as any).speechSynthesis !== 'undefined' ? (globalThis as any).speechSynthesis : null);
if (synth) {
try {
synth.cancel();
} catch (e) {}
this.activeUtterance = null;
}
}
}
export const defaultVoiceAudition = new VoiceAuditionService();