initialize Electron desktop app and remove unused files
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const audioConvertMock = vi.hoisted(() => ({
|
||||
transcodeToWav: vi.fn(async (audio: Buffer) => ({
|
||||
audio: Buffer.from(`wav:${audio.toString('utf-8')}`),
|
||||
mimeType: 'audio/wav',
|
||||
fileName: 'audio.wav',
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/stt-providers/audio-convert', () => audioConvertMock)
|
||||
|
||||
import { transcribeWithProvider } from '../../packages/server/src/services/hermes/stt-providers'
|
||||
import { transcribeDoubaoFile } from '../../packages/server/src/services/hermes/stt-providers/doubao'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
function jsonResponse(body: unknown, init: { status?: number; statusText?: string; apiStatus?: string } = {}) {
|
||||
return {
|
||||
ok: (init.status ?? 200) >= 200 && (init.status ?? 200) < 300,
|
||||
status: init.status ?? 200,
|
||||
statusText: init.statusText ?? 'OK',
|
||||
headers: new Headers(init.apiStatus ? { 'X-Api-Status-Code': init.apiStatus } : {}),
|
||||
async json() {
|
||||
return body
|
||||
},
|
||||
async text() {
|
||||
return typeof body === 'string' ? body : JSON.stringify(body)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getHeader(headers: RequestInit['headers'] | undefined, name: string): string | undefined {
|
||||
if (!headers) return undefined
|
||||
if (headers instanceof Headers) return headers.get(name) ?? undefined
|
||||
if (Array.isArray(headers)) {
|
||||
const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase())
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
const match = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
||||
return typeof match?.[1] === 'string' ? match[1] : undefined
|
||||
}
|
||||
|
||||
function getCall(index: number): [string, RequestInit] {
|
||||
const [url, init] = mockFetch.mock.calls[index] as [string | URL, RequestInit]
|
||||
return [typeof url === 'string' ? url : url.toString(), init]
|
||||
}
|
||||
|
||||
function bodyJson(init: RequestInit): any {
|
||||
expect(typeof init.body).toBe('string')
|
||||
return JSON.parse(init.body as string)
|
||||
}
|
||||
|
||||
describe('transcribeDoubaoFile', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
audioConvertMock.transcodeToWav.mockReset()
|
||||
audioConvertMock.transcodeToWav.mockImplementation(async (audio: Buffer) => ({
|
||||
audio: Buffer.from(`wav:${audio.toString('utf-8')}`),
|
||||
mimeType: 'audio/wav',
|
||||
fileName: 'audio.wav',
|
||||
}))
|
||||
})
|
||||
|
||||
it('submits base64 audio and polls the Doubao audio-file recognition result', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({}, { apiStatus: '20000000' }))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
result: {
|
||||
text: '打开西瓜白榜。',
|
||||
utterances: [],
|
||||
},
|
||||
}, { apiStatus: '20000000' }))
|
||||
|
||||
const result = await transcribeDoubaoFile({
|
||||
provider: 'doubao',
|
||||
audio: Buffer.from('wav-audio'),
|
||||
fileName: 'speech.wav',
|
||||
mimeType: 'audio/wav',
|
||||
settings: {
|
||||
baseUrl: 'https://openspeech.bytedance.com/api/v3/auc/bigmodel',
|
||||
model: 'volc.seedasr.auc',
|
||||
language: 'zh',
|
||||
prompt: '西瓜白榜',
|
||||
},
|
||||
secrets: {
|
||||
apiKey: 'server-secret',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
text: '打开西瓜白榜。',
|
||||
provider: 'doubao',
|
||||
model: 'volc.seedasr.auc',
|
||||
language: 'zh',
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
|
||||
const [submitUrl, submitInit] = getCall(0)
|
||||
expect(submitUrl).toBe('https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit')
|
||||
expect(getHeader(submitInit.headers, 'X-Api-Key')).toBe('server-secret')
|
||||
expect(getHeader(submitInit.headers, 'X-Api-Resource-Id')).toBe('volc.seedasr.auc')
|
||||
expect(getHeader(submitInit.headers, 'X-Api-Sequence')).toBe('-1')
|
||||
const requestId = getHeader(submitInit.headers, 'X-Api-Request-Id')
|
||||
expect(requestId).toEqual(expect.any(String))
|
||||
|
||||
const submitBody = bodyJson(submitInit)
|
||||
expect(submitBody.audio).toEqual({
|
||||
format: 'wav',
|
||||
data: Buffer.from('wav-audio').toString('base64'),
|
||||
})
|
||||
expect(submitBody.request).toMatchObject({
|
||||
model_name: 'bigmodel',
|
||||
enable_itn: true,
|
||||
enable_punc: true,
|
||||
show_utterances: true,
|
||||
language: 'zh',
|
||||
corpus: { context: '西瓜白榜' },
|
||||
})
|
||||
|
||||
const [queryUrl, queryInit] = getCall(1)
|
||||
expect(queryUrl).toBe('https://openspeech.bytedance.com/api/v3/auc/bigmodel/query')
|
||||
expect(getHeader(queryInit.headers, 'X-Api-Request-Id')).toBe(requestId)
|
||||
expect(bodyJson(queryInit)).toEqual({})
|
||||
expect(audioConvertMock.transcodeToWav).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches through the shared provider entrypoint', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({}, { apiStatus: '20000000' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ result: { text: 'shared dispatch' } }, { apiStatus: '20000000' }))
|
||||
|
||||
const result = await transcribeWithProvider({
|
||||
provider: 'doubao',
|
||||
audio: Buffer.from('audio'),
|
||||
fileName: 'speech.wav',
|
||||
mimeType: 'audio/wav',
|
||||
settings: {},
|
||||
secrets: {
|
||||
apiKey: 'server-secret',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.text).toBe('shared dispatch')
|
||||
expect(result.model).toBe('volc.seedasr.auc')
|
||||
})
|
||||
|
||||
it('transcodes browser webm audio to wav before upload when possible', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({}, { apiStatus: '20000000' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ result: { text: 'converted' } }, { apiStatus: '20000000' }))
|
||||
|
||||
await transcribeDoubaoFile({
|
||||
provider: 'doubao',
|
||||
audio: Buffer.from('webm-audio'),
|
||||
fileName: 'speech.webm',
|
||||
mimeType: 'audio/webm',
|
||||
settings: {},
|
||||
secrets: {
|
||||
apiKey: 'server-secret',
|
||||
},
|
||||
})
|
||||
|
||||
expect(audioConvertMock.transcodeToWav).toHaveBeenCalledWith(Buffer.from('webm-audio'), 'audio/webm', {
|
||||
normalizeWav: false,
|
||||
})
|
||||
expect(bodyJson(getCall(0)[1]).audio).toEqual({
|
||||
format: 'wav',
|
||||
data: Buffer.from('wav:webm-audio').toString('base64'),
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes wav audio when ffmpeg transcoding is requested', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({}, { apiStatus: '20000000' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ result: { text: 'normalized' } }, { apiStatus: '20000000' }))
|
||||
|
||||
await transcribeDoubaoFile({
|
||||
provider: 'doubao',
|
||||
audio: Buffer.from('mcu-wav'),
|
||||
fileName: 'mcu-voice.wav',
|
||||
mimeType: 'audio/wav',
|
||||
settings: {
|
||||
audioTranscode: 'ffmpeg',
|
||||
},
|
||||
secrets: {
|
||||
apiKey: 'server-secret',
|
||||
},
|
||||
})
|
||||
|
||||
expect(audioConvertMock.transcodeToWav).toHaveBeenCalledWith(Buffer.from('mcu-wav'), 'audio/wav', {
|
||||
normalizeWav: true,
|
||||
})
|
||||
expect(bodyJson(getCall(0)[1]).audio).toEqual({
|
||||
format: 'wav',
|
||||
data: Buffer.from('wav:mcu-wav').toString('base64'),
|
||||
})
|
||||
})
|
||||
|
||||
it('fails clearly when browser webm audio cannot be transcoded', async () => {
|
||||
audioConvertMock.transcodeToWav.mockResolvedValueOnce({
|
||||
audio: Buffer.from('webm-audio'),
|
||||
mimeType: 'audio/webm',
|
||||
fileName: '',
|
||||
})
|
||||
|
||||
const error = await transcribeDoubaoFile({
|
||||
provider: 'doubao',
|
||||
audio: Buffer.from('webm-audio'),
|
||||
fileName: 'speech.webm',
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
settings: {},
|
||||
secrets: {
|
||||
apiKey: 'server-secret',
|
||||
},
|
||||
}).catch(error => error)
|
||||
|
||||
expect(String(error)).toContain('browser WebM/Opus recordings can be transcoded to WAV')
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redacts the API key in upstream errors', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('bad server-secret', { status: 403, statusText: 'Forbidden', apiStatus: '1001' }))
|
||||
|
||||
const error = await transcribeDoubaoFile({
|
||||
provider: 'doubao',
|
||||
audio: Buffer.from('audio'),
|
||||
fileName: 'speech.wav',
|
||||
mimeType: 'audio/wav',
|
||||
settings: {},
|
||||
secrets: {
|
||||
apiKey: 'server-secret',
|
||||
},
|
||||
}).catch(error => error)
|
||||
|
||||
expect(String(error)).toContain('Doubao STT submit failed')
|
||||
expect(String(error)).not.toContain('server-secret')
|
||||
expect(String(error)).toContain('[redacted]')
|
||||
})
|
||||
|
||||
it('maps Doubao silent-audio status to a no-speech error', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({}, { apiStatus: '20000000' }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { apiStatus: '20000003' }))
|
||||
|
||||
const error = await transcribeDoubaoFile({
|
||||
provider: 'doubao',
|
||||
audio: Buffer.from('quiet-wav'),
|
||||
fileName: 'speech.wav',
|
||||
mimeType: 'audio/wav',
|
||||
settings: {},
|
||||
secrets: {
|
||||
apiKey: 'server-secret',
|
||||
},
|
||||
}).catch(error => error)
|
||||
|
||||
expect(error?.constructor?.name).toBe('SttNoSpeechDetectedError')
|
||||
expect(String(error)).toContain('No speech detected')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user