// tests/main/services/STTManager.test.ts // STTManager 및 오디오 변환 유틸리티 단위 테스트 import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager' import { pcmToWav, createProbeWav } from '../../../src/main/services/stt/audio-utils' import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService' import { OpenAIDriver } from '../../../src/main/services/stt/drivers/OpenAIDriver' import { GroqDriver } from '../../../src/main/services/stt/drivers/GroqDriver' import { DeepgramDriver } from '../../../src/main/services/stt/drivers/DeepgramDriver' import { AssemblyAIDriver } from '../../../src/main/services/stt/drivers/AssemblyAIDriver' import { GoogleDriver } from '../../../src/main/services/stt/drivers/GoogleDriver' import { CustomDriver } from '../../../src/main/services/stt/drivers/CustomDriver' import { D3ROCloudDriver } from '../../../src/main/services/stt/drivers/D3ROCloudDriver' function makeCloudDriver(authenticated = true): D3ROCloudDriver { return new D3ROCloudDriver({ getAccessToken: async () => authenticated ? 'user.jwt.token' : null, getSupabaseUrl: () => 'https://project.supabase.co', getAnonKey: () => 'anon-key', }) } describe('STTManager & Multi-provider Drivers', () => { beforeEach(() => { initInMemoryConfig() resetSTTManagerForTests() vi.restoreAllMocks() }) afterEach(() => { resetInMemoryConfig() resetSTTManagerForTests() vi.restoreAllMocks() }) describe('Audio Utilities (PCM to WAV)', () => { it('pcmToWav converts 16kHz mono PCM buffer into valid RIFF WAV with 44-byte header', () => { const pcmData = Buffer.alloc(3200) // 100ms at 16kHz 16-bit pcmData.fill(128) const wav = pcmToWav(pcmData, 16000, 1, 16) expect(wav.length).toBe(3200 + 44) // Check RIFF header expect(wav.toString('ascii', 0, 4)).toBe('RIFF') expect(wav.readUInt32LE(4)).toBe(wav.length - 8) expect(wav.toString('ascii', 8, 12)).toBe('WAVE') expect(wav.toString('ascii', 12, 16)).toBe('fmt ') expect(wav.readUInt32LE(16)).toBe(16) // Subchunk1Size expect(wav.readUInt16LE(20)).toBe(1) // AudioFormat PCM expect(wav.readUInt16LE(22)).toBe(1) // NumChannels 1 expect(wav.readUInt32LE(24)).toBe(16000) // SampleRate 16000 expect(wav.readUInt16LE(34)).toBe(16) // BitsPerSample 16 expect(wav.toString('ascii', 36, 40)).toBe('data') expect(wav.readUInt32LE(40)).toBe(3200) // DataSize }) it('createProbeWav produces non-empty WAV probe for latency testing', () => { const probe = createProbeWav(300, 16000) expect(probe.length).toBeGreaterThan(44) expect(probe.toString('ascii', 0, 4)).toBe('RIFF') expect(probe.toString('ascii', 8, 12)).toBe('WAVE') }) }) describe('STTManager Provider Management', () => { it('getProviders returns 8 providers with metadata', () => { const mgr = getSTTManager() const providers = mgr.getProviders() expect(providers.length).toBe(8) const ids = providers.map((p) => p.id) expect(ids).toContain('local') expect(ids).toContain('d3ro-cloud') expect(ids).toContain('openai') expect(ids).toContain('groq') expect(ids).toContain('deepgram') expect(ids).toContain('assemblyai') expect(ids).toContain('google') expect(ids).toContain('custom') }) it('setProvider and getActiveProvider roundtrip', () => { const mgr = getSTTManager() expect(mgr.getActiveProvider()).toBe('local') mgr.setProvider('openai') expect(mgr.getActiveProvider()).toBe('openai') mgr.setProvider('groq') expect(mgr.getActiveProvider()).toBe('groq') }) it('setProviderConfig and getProviderConfig roundtrip', () => { const mgr = getSTTManager() mgr.setProviderConfig('openai', { apiKey: 'sk-test-12345', modelId: 'whisper-1', }) const cfg = mgr.getProviderConfig('openai') expect(cfg.apiKey).toBe('sk-test-12345') expect(cfg.modelId).toBe('whisper-1') }) it('testConnection for local returns ready immediately', async () => { const mgr = getSTTManager() const result = await mgr.testConnection({ provider: 'local' }) expect(result.success).toBe(true) expect(result.latencyMs).toBe(0) }) }) describe('OpenAIDriver', () => { it('transcribes audio via OpenAI Whisper format with mocked 200 response', async () => { const driver = new OpenAIDriver() const mockAudio = Buffer.alloc(16000 * 2) // 1 second PCM const mockResponse = { text: '안녕하세요 D3RO 음성 인식입니다.', language: 'ko', duration: 1.0, segments: [ { text: '안녕하세요 D3RO 음성 인식입니다.', start: 0, end: 1.0, avg_logprob: -0.1 }, ], } vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => mockResponse, }) ) const result = await driver.transcribe(mockAudio, { language: 'ko' }, { apiKey: 'sk-mock-key' }) expect(result.text).toBe('안녕하세요 D3RO 음성 인식입니다.') expect(result.language).toBe('ko') expect(result.segments.length).toBe(1) }) it('throws error when apiKey is missing', async () => { const driver = new OpenAIDriver() await expect(driver.transcribe(Buffer.alloc(100))).rejects.toThrow('API Key') }) it('testConnection returns success: true on valid API response', async () => { const driver = new OpenAIDriver() vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ text: 'OK' }), }) ) const result = await driver.testConnection({ apiKey: 'sk-mock-key' }) expect(result.success).toBe(true) expect(result.message).toContain('연결 성공') }) }) describe('GroqDriver', () => { it('transcribes audio via Groq Whisper LPU with mocked response', async () => { const driver = new GroqDriver() const mockAudio = Buffer.alloc(16000 * 2) const mockResponse = { text: 'Groq ultra-fast transcription.', language: 'en', duration: 1.0, segments: [{ text: 'Groq ultra-fast transcription.', start: 0, end: 1.0 }], } vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => mockResponse, }) ) const result = await driver.transcribe(mockAudio, { language: 'en' }, { apiKey: 'gsk_mock_key' }) expect(result.text).toBe('Groq ultra-fast transcription.') expect(result.language).toBe('en') }) }) describe('DeepgramDriver', () => { it('transcribes audio via Deepgram Nova-3 format with mocked response', async () => { const driver = new DeepgramDriver() const mockAudio = Buffer.alloc(16000 * 2) const mockResponse = { metadata: { duration: 1.0 }, results: { channels: [ { alternatives: [ { transcript: 'Deepgram Nova 3 speech recognition test.', confidence: 0.99, detected_language: 'en', words: [ { word: 'Deepgram', start: 0, end: 0.3, confidence: 0.99 }, { word: 'Nova', start: 0.3, end: 0.5, confidence: 0.99 }, ], }, ], }, ], }, } vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => mockResponse, }) ) const result = await driver.transcribe(mockAudio, { language: 'en' }, { apiKey: 'mock_deepgram_key' }) expect(result.text).toBe('Deepgram Nova 3 speech recognition test.') expect(result.segments.length).toBe(2) }) }) describe('GoogleDriver (Gemini Flash Audio)', () => { it('transcribes audio via Gemini Flash Audio format with mocked response', async () => { const driver = new GoogleDriver() const mockAudio = Buffer.alloc(16000 * 2) const mockResponse = { candidates: [ { content: { parts: [{ text: 'Google Gemini 2.0 Flash 전사 결과입니다.' }], }, }, ], } vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => mockResponse, }) ) const result = await driver.transcribe(mockAudio, { language: 'ko' }, { apiKey: 'mock_google_key' }) expect(result.text).toBe('Google Gemini 2.0 Flash 전사 결과입니다.') }) }) describe('AssemblyAIDriver', () => { it('uploads audio and polls transcript with mocked response', async () => { const driver = new AssemblyAIDriver() const mockAudio = Buffer.alloc(16000 * 2) vi.stubGlobal( 'fetch', vi.fn() .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ upload_url: 'https://cdn.assemblyai.com/test.wav' }), }) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ id: 'transcript-job-123', status: 'queued' }), }) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ id: 'transcript-job-123', status: 'completed', text: 'AssemblyAI transcription completed.', audio_duration: 1.0, words: [{ text: 'AssemblyAI', start: 0, end: 500, confidence: 0.98 }], }), }) ) const result = await driver.transcribe(mockAudio, { language: 'en' }, { apiKey: 'mock_assembly_key' }) expect(result.text).toBe('AssemblyAI transcription completed.') expect(result.segments.length).toBe(1) }) }) describe('CustomDriver', () => { it('transcribes audio via custom OpenAI-compatible endpoint', async () => { const driver = new CustomDriver() const mockAudio = Buffer.alloc(16000 * 2) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ text: 'Custom endpoint transcription.' }), }) ) const result = await driver.transcribe( mockAudio, { language: 'en' }, { baseUrl: 'http://localhost:8000/v1', modelId: 'whisper-1' } ) expect(result.text).toBe('Custom endpoint transcription.') }) }) describe('D3ROCloudDriver', () => { it('transcribes audio via D3RO Cloud STT Gateway endpoint without requiring individual provider keys', async () => { const driver = makeCloudDriver() const mockAudio = Buffer.alloc(16000 * 2) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ transcript: 'D3RO 클라우드 매니지드 전사 결과입니다.', confidence: 0.98, language_code: 'ko', duration_seconds: 1.0, provider: 'groq', }), }) ) const result = await driver.transcribe( mockAudio, { language: 'ko' }, { baseUrl: 'http://localhost:5000' } ) expect(result.text).toBe('D3RO 클라우드 매니지드 전사 결과입니다.') expect(result.language).toBe('ko') }) }) describe('STTManager Auto-Fallback', () => { it('falls back to Local Whisper if cloud STT fails and fallback is enabled', async () => { const mgr = getSTTManager() mgr.setProvider('openai') mgr.setProviderConfig('openai', { apiKey: 'sk-invalid-key' }) configSet('sttFallbackToLocal', true) // Mock cloud driver failing vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, text: async () => 'Unauthorized', }) ) // Mock LocalSTTService transcribe const mockLocalResult = { text: '로컬 Whisper 폴백 성공', language: 'ko', duration: 1.0, processingTime: 100, segments: [], } const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService') vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue(mockLocalResult) const result = await mgr.transcribe(Buffer.alloc(16000 * 2)) expect(result.text).toBe('로컬 Whisper 폴백 성공') }) }) })