d3ro-voice/apps/desktop/tests/main/services/STTManager.test.ts
Yun Chan 2d585bfc29 feat(desktop): make local speech transcription work end to end
Local dictation had never produced a transcript on an installed build. The
engine itself was healthy; every connection to it was broken.

Installed builds shipped no speech engine at all: the packaging config had no
entry for the faster-whisper sidecar and no pipeline step built one, so the app
always fell back to a system Python without the runtime. Development was broken
too, because the sidecar and SoX paths were resolved against the Vite output
directory instead of the app root, which also meant recording failed with a SoX
ENOENT. On hosts where localhost resolves only to IPv6, every local request was
refused outright, which silently disabled both local transcription and the local
LLM.

The sidecar is now built and bundled (including the Silero VAD data it needs),
gated by a packaging check that fails when the engine or its data is missing.
Paths are discovered from the app root and fail loudly when the engine is
absent. Local engine URLs are normalized to the IPv4 loopback, decoding is tuned
so repeated hallucinations cannot compound (the same transcript now takes about
a fifth of the time), the engine is warmed up at startup, and holding the hotkey
now shows the text forming live in the recording tip.
2026-09-18 00:48:47 +09:00

426 lines
14 KiB
TypeScript

// 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 폴백 성공')
})
})
describe('STTManager Live Partial (미리보기)', () => {
it('routes partial transcription through the local engine', async () => {
const mgr = getSTTManager()
mgr.setProvider('local')
const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService')
const partialSpy = vi
.spyOn(getLocalSTTService(), 'transcribePartial')
.mockResolvedValue('미리보기 텍스트')
await expect(mgr.transcribePartial(Buffer.alloc(32000))).resolves.toBe('미리보기 텍스트')
expect(partialSpy).toHaveBeenCalledOnce()
})
it('does not call the local engine for cloud providers', async () => {
const mgr = getSTTManager()
mgr.setProvider('groq')
mgr.setProviderConfig('groq', { apiKey: 'gsk-test' })
const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService')
const partialSpy = vi.spyOn(getLocalSTTService(), 'transcribePartial')
await expect(mgr.transcribePartial(Buffer.alloc(32000))).resolves.toBe('')
expect(partialSpy).not.toHaveBeenCalled()
})
it('warms up the local engine only for the local provider', async () => {
const mgr = getSTTManager()
const { getLocalSTTService } = await import('../../../src/main/services/LocalSTTService')
const warmSpy = vi.spyOn(getLocalSTTService(), 'warmUp').mockResolvedValue(true)
mgr.setProvider('local')
await expect(mgr.warmUpLocal()).resolves.toBe(true)
mgr.setProvider('deepgram')
await expect(mgr.warmUpLocal()).resolves.toBe(false)
expect(warmSpy).toHaveBeenCalledOnce()
})
})
})