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

+206
View File
@@ -0,0 +1,206 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { VoiceAuditionService } from '../services/voiceAuditionService';
import { VOICE_PROFILES } from '../constants';
describe('VoiceAuditionService and Natural Voice Calibration', () => {
let service: VoiceAuditionService;
beforeEach(() => {
service = new VoiceAuditionService();
});
afterEach(() => {
service.stop();
vi.restoreAllMocks();
});
test('VOICE_PROFILES contains Fenrir as the gruff salt-of-the-earth persona mapped to onyx', () => {
const fenrir = VOICE_PROFILES.find((p) => p.id === 'Fenrir');
expect(fenrir).toBeDefined();
expect(fenrir?.label).toContain('Salt of the Earth');
expect(fenrir?.style).toContain('Gruff');
expect(fenrir?.openRouterVoice).toBe('onyx'); // OpenAI/OpenRouter deep, gravelly voice
expect(fenrir?.webSpeechTuning.pitch).toBeLessThan(1.0); // Low, gravelly pitch
expect(fenrir?.sampleText).toContain('honest step at a time');
});
test('VOICE_PROFILES includes 5 distinct clinical voice personas', () => {
expect(VOICE_PROFILES.length).toBe(5);
const voiceIds = VOICE_PROFILES.map((p) => p.id);
expect(voiceIds).toEqual(['Fenrir', 'Zephyr', 'Puck', 'Kore', 'Charon']);
});
test('getProfile returns matching VoiceProfile or falls back to default', () => {
const profile = service.getProfile('Fenrir');
expect(profile.id).toBe('Fenrir');
expect(profile.openRouterVoice).toBe('onyx');
// @ts-expect-error test fallback
const fallback = service.getProfile('NonExistent');
expect(fallback.id).toBe('Fenrir');
});
test('playPreview uses Google Gemini native voice when Gemini API key is available', async () => {
const mockWavBase64 = 'UklGRgAAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
candidates: [{
content: {
parts: [{
inlineData: {
mimeType: 'audio/wav',
data: mockWavBase64
}
}]
}
}]
})
}));
const playMock = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('Audio', class MockAudio {
src = '';
play = playMock;
pause = vi.fn();
currentTime = 0;
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(src: string) {
this.src = src;
}
});
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue('blob:http://localhost/test-gemini-audio'),
revokeObjectURL: vi.fn()
});
await service.playPreview('Fenrir', 'Custom test sentence', undefined, undefined, 'AIzaSyMockKey');
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('gemini-3.8-flash-tts'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"voiceName":"Fenrir"')
})
);
expect(playMock).toHaveBeenCalled();
});
test('playPreview uses OpenRouter TTS when apiKey is provided and no Gemini key', async () => {
const originalGemini = process.env.GEMINI_API_KEY;
try {
process.env.GEMINI_API_KEY = '';
const mockArrayBuffer = new Uint8Array([1, 2, 3, 4]).buffer;
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: async () => mockArrayBuffer
}));
const playMock = vi.fn().mockResolvedValue(undefined);
let capturedAudioInstance: any;
vi.stubGlobal('Audio', class MockAudio {
src = '';
play = playMock;
pause = vi.fn();
currentTime = 0;
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(src: string) {
this.src = src;
capturedAudioInstance = this;
}
});
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue('blob:http://localhost/test-audio'),
revokeObjectURL: vi.fn()
});
const onEnded = vi.fn();
await service.playPreview('Fenrir', 'Custom test sentence', 'sk-or-v1-test-key', onEnded);
expect(global.fetch).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/audio/speech',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
model: 'openai/tts-1',
input: 'Custom test sentence',
voice: 'onyx' // Verified: Fenrir maps to onyx
})
})
);
expect(playMock).toHaveBeenCalled();
// Trigger onended
capturedAudioInstance?.onended?.();
expect(onEnded).toHaveBeenCalled();
} finally {
if (originalGemini !== undefined) process.env.GEMINI_API_KEY = originalGemini;
}
});
test('playPreview falls back to Web Speech Synthesis when no apiKey is provided', async () => {
const originalGemini = process.env.GEMINI_API_KEY;
const originalOR = process.env.OPENROUTER_API_KEY;
try {
process.env.GEMINI_API_KEY = '';
process.env.OPENROUTER_API_KEY = '';
const speakMock = vi.fn();
const cancelMock = vi.fn();
vi.stubGlobal('speechSynthesis', {
speak: speakMock,
cancel: cancelMock
});
vi.stubGlobal('SpeechSynthesisUtterance', class MockUtterance {
text: string;
pitch = 1.0;
rate = 1.0;
onend: (() => void) | null = null;
constructor(text: string) {
this.text = text;
}
});
const onEnded = vi.fn();
await service.playPreview('Fenrir', undefined, undefined, onEnded);
expect(speakMock).toHaveBeenCalled();
const utterance = speakMock.mock.calls[0][0];
expect(utterance.text).toContain('honest step at a time');
expect(utterance.pitch).toBe(0.75); // Gruff low pitch verified
expect(utterance.rate).toBe(0.92);
} finally {
if (originalGemini !== undefined) process.env.GEMINI_API_KEY = originalGemini;
if (originalOR !== undefined) process.env.OPENROUTER_API_KEY = originalOR;
}
});
test('stop halts both HTMLAudioElement and speechSynthesis', async () => {
const pauseMock = vi.fn();
const cancelMock = vi.fn();
vi.stubGlobal('speechSynthesis', {
cancel: cancelMock,
speak: vi.fn()
});
// @ts-expect-error simulate active audio
service.currentAudio = {
pause: pauseMock,
currentTime: 10
};
service.stop();
expect(pauseMock).toHaveBeenCalled();
expect(cancelMock).toHaveBeenCalled();
});
});