Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
378 lines
12 KiB
TypeScript
378 lines
12 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'
|
|
|
|
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 = new D3ROCloudDriver()
|
|
const mockAudio = Buffer.alloc(16000 * 2)
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
text: 'D3RO 클라우드 매니지드 전사 결과입니다.',
|
|
language: 'ko',
|
|
durationSeconds: 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 폴백 성공')
|
|
})
|
|
})
|
|
})
|
|
|