import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from './works-square-session'; const MAX_AUDIO_BYTES = 26_214_400; const MAX_TRANSCRIPT_LENGTH = 16_000; export type LearningSpeechRequest = { audio: Uint8Array; fileName: string; mimeType: string; language?: string; }; type Dependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; apiBaseUrl?: string; }; export function createLearningSpeechClient(dependencies: Dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); async function transcribe( input: LearningSpeechRequest, assertCurrentAccount: () => void = () => undefined, ): Promise<{ text: string }> { const audio = input.audio instanceof Uint8Array ? input.audio : new Uint8Array(input.audio); if (!audio.byteLength || audio.byteLength > MAX_AUDIO_BYTES) throw new Error('语音长度不符合要求'); assertCurrentAccount(); const token = await getAccessToken({ fetchImpl }).catch(() => { throw new Error('语音识别失败'); }); assertCurrentAccount(); if (!token) throw new Error('请先登录'); const form = new FormData(); form.set('audio', new Blob([audio], { type: input.mimeType || 'audio/webm' }), input.fileName || 'voice.webm'); if (input.language?.trim()) form.set('language', input.language.trim()); const request = (accessToken: string) => { assertCurrentAccount(); return fetchImpl(`${apiBaseUrl}/api/speech/transcriptions`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, body: form, }); }; let response = await request(token).catch(() => { throw new Error('语音识别失败'); }); assertCurrentAccount(); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); assertCurrentAccount(); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }).catch(() => null); assertCurrentAccount(); if (refreshed) { response = await request(refreshed).catch(() => { throw new Error('语音识别失败'); }); assertCurrentAccount(); } } const payload = await response.json().catch(() => null) as { text?: unknown; detail?: unknown } | null; const text = typeof payload?.text === 'string' ? payload.text.trim() : ''; if (!response.ok || !text || text.length > MAX_TRANSCRIPT_LENGTH) throw new Error('语音识别失败'); return { text }; } return { transcribe }; }