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:
1 parent
c82b112624
commit
1feea40684
148 files changed
+11230
-1706
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user