- 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
207 lines
6.5 KiB
TypeScript
207 lines
6.5 KiB
TypeScript
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();
|
|
});
|
|
});
|