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(); }); });