Skip to content

Commit e00e74e

Browse files
feat: add Volcengine file transcription support
- Create volcFileApi.ts with flash (sync) recognize API using base64 upload - Add executeVolc function in useFileTranscription hook - Update VolcProvider to mark fileTranscription as compatible - All providers now support file transcription Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 61729e1 commit e00e74e

3 files changed

Lines changed: 215 additions & 2 deletions

File tree

frontend/src/hooks/useFileTranscription.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ import {
5252
type AssemblyAIWord,
5353
type AssemblyAIUtterance,
5454
} from '../utils/assemblyaiFileApi'
55+
import {
56+
transcribeFile as volcTranscribeFile,
57+
type VolcWord,
58+
type VolcUtterance,
59+
} from '../utils/volcFileApi'
5560

5661
/* ─── Soniox result conversion ──────────────────────────────── */
5762

@@ -802,6 +807,91 @@ async function executeAssemblyAI(
802807
return { transcript, tokens, segments, speakers, durationMs }
803808
}
804809

810+
/* ─── Volcengine result conversion ──────────────────────────── */
811+
812+
function volcWordsToTokens(words: VolcWord[]): TranscriptTokenData[] {
813+
return words.map((w) => ({
814+
text: w.text,
815+
isFinal: true as const,
816+
startMs: w.start_time,
817+
endMs: w.end_time,
818+
confidence: w.confidence,
819+
}))
820+
}
821+
822+
function volcUtterancesToSegments(utterances: VolcUtterance[]): TranscriptSegment[] {
823+
return utterances.map((u) => ({
824+
text: u.text,
825+
startMs: u.start_time,
826+
endMs: u.end_time,
827+
isFinal: true as const,
828+
speakerId: u.speaker,
829+
}))
830+
}
831+
832+
function volcExtractSpeakers(utterances: VolcUtterance[]): TranscriptSpeaker[] {
833+
const speakerSet = new Set<string>()
834+
for (const u of utterances) {
835+
if (u.speaker) speakerSet.add(u.speaker)
836+
}
837+
return Array.from(speakerSet)
838+
.sort()
839+
.map((id) => ({ id, label: `Speaker ${id}` }))
840+
}
841+
842+
async function executeVolc(
843+
file: File,
844+
config: FileTranscriptionConfig,
845+
appKey: string,
846+
accessKey: string,
847+
jobId: string,
848+
updateJob: (id: string, u: Record<string, unknown>) => void,
849+
signal: AbortSignal,
850+
): Promise<TranscriptionResult> {
851+
updateJob(jobId, { status: 'uploading', progress: 20 })
852+
853+
updateJob(jobId, { status: 'transcribing', progress: 40 })
854+
855+
const response = await volcTranscribeFile(
856+
appKey,
857+
accessKey,
858+
file,
859+
{
860+
enableSpeakerInfo: config.enableSpeakerDiarization,
861+
enableItn: true,
862+
enablePunc: true,
863+
enableDdc: true,
864+
},
865+
signal,
866+
)
867+
868+
updateJob(jobId, { progress: 90 })
869+
870+
const transcript = response.result.text ?? ''
871+
const utterances = response.result.utterances ?? []
872+
const durationMs = response.audio_info.duration ?? 0
873+
874+
console.debug('[Volcengine] Transcript length:', transcript.length, 'Utterances:', utterances.length, 'Duration:', durationMs)
875+
876+
if (!transcript && utterances.length === 0) {
877+
console.warn('[Volcengine] Empty transcription result. Full response:', JSON.stringify(response))
878+
throw new Error('火山引擎返回了空的转录结果,请检查音频文件或尝试其他提供商。')
879+
}
880+
881+
const allWords = utterances.flatMap((u) => u.words ?? [])
882+
const tokens: TranscriptTokenData[] = allWords.length > 0
883+
? volcWordsToTokens(allWords)
884+
: [{ text: transcript, isFinal: true as const, startMs: 0, endMs: durationMs }]
885+
886+
const segments: TranscriptSegment[] = utterances.length > 0
887+
? volcUtterancesToSegments(utterances)
888+
: [{ text: transcript.trim(), startMs: 0, endMs: durationMs, isFinal: true as const }]
889+
890+
const speakers = volcExtractSpeakers(utterances)
891+
892+
return { transcript, tokens, segments, speakers, durationMs }
893+
}
894+
805895
/* ─── Deepgram helpers ─────────────────────────────────────── */
806896

807897
function parseDeepgramResponse(response: import('../utils/deepgramFileApi').DeepgramFileTranscriptionResponse) {
@@ -915,6 +1005,12 @@ export function useFileTranscription() {
9151005
if (!apiToken || !accountId) {
9161006
throw new Error('Cloudflare API Token 或 Account ID 未配置')
9171007
}
1008+
} else if (providerId === 'volc') {
1009+
const appKey = providerConfig?.appKey as string | undefined
1010+
const accessKey = providerConfig?.accessKey as string | undefined
1011+
if (!appKey || !accessKey) {
1012+
throw new Error('火山引擎 APP ID 或 Access Token 未配置')
1013+
}
9181014
} else if (!apiKey) {
9191015
throw new Error(`${providerId} API Key not configured`)
9201016
}
@@ -951,6 +1047,10 @@ export function useFileTranscription() {
9511047
result = await executeDeepgram(file, config, apiKey!, jobId, updateJob, controller.signal)
9521048
} else if (providerId === 'assemblyai') {
9531049
result = await executeAssemblyAI(file, config, apiKey!, jobId, updateJob, controller.signal)
1050+
} else if (providerId === 'volc') {
1051+
const volcAppKey = providerConfig?.appKey as string
1052+
const volcAccessKey = providerConfig?.accessKey as string
1053+
result = await executeVolc(file, config, volcAppKey, volcAccessKey, jobId, updateJob, controller.signal)
9541054
} else {
9551055
result = await executeSoniox(file, config, apiKey!, jobId, updateJob, controller.signal)
9561056
}

frontend/src/providers/implementations/VolcProvider.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class VolcProvider extends BaseASRProvider {
2222
readonly info: ASRProviderInfo = {
2323
id: 'volc' as ASRVendor,
2424
name: '火山引擎',
25-
description: '字节跳动旗下语音识别服务,支持中文优化',
25+
description: '字节跳动旗下语音识别服务,支持中文优化,实时 + 文件转录',
2626
type: 'cloud',
2727
supportsStreaming: true,
2828
capabilities: {
@@ -48,7 +48,10 @@ export class VolcProvider extends BaseASRProvider {
4848
acceptedFileKinds: ['audio'],
4949
},
5050
fileTranscription: {
51-
availability: 'unsupported',
51+
availability: 'compatible',
52+
executionMode: 'single-request',
53+
inputSources: ['file'],
54+
acceptedFileKinds: ['audio'],
5255
},
5356
},
5457
supportsConfigTest: true,

frontend/src/utils/volcFileApi.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* 火山引擎 豆包语音 — 大模型录音文件极速版识别 API
3+
*
4+
* 同步模式:一次请求即返回识别结果
5+
* 音频限制:≤ 2h / ≤ 100MB,支持 WAV/MP3/OGG
6+
* 认证方式:Header 传 AppKey + AccessKey(旧版控制台)
7+
*/
8+
9+
const VOLC_RECOGNIZE_URL = 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash'
10+
11+
/* ─── Response types ──────────────────────────────────────── */
12+
13+
export interface VolcWord {
14+
text: string
15+
start_time: number
16+
end_time: number
17+
confidence: number
18+
}
19+
20+
export interface VolcUtterance {
21+
text: string
22+
start_time: number
23+
end_time: number
24+
words?: VolcWord[]
25+
speaker?: string
26+
}
27+
28+
export interface VolcRecognizeResponse {
29+
audio_info: {
30+
duration: number
31+
}
32+
result: {
33+
text: string
34+
utterances?: VolcUtterance[]
35+
additions?: {
36+
duration?: string
37+
}
38+
}
39+
}
40+
41+
export interface VolcTranscribeParams {
42+
enableSpeakerInfo?: boolean
43+
enableItn?: boolean
44+
enablePunc?: boolean
45+
enableDdc?: boolean
46+
}
47+
48+
/* ─── Helpers ─────────────────────────────────────────────── */
49+
50+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
51+
const bytes = new Uint8Array(buffer)
52+
let binary = ''
53+
for (let i = 0; i < bytes.length; i++) {
54+
binary += String.fromCharCode(bytes[i])
55+
}
56+
return btoa(binary)
57+
}
58+
59+
/* ─── Transcribe file (flash / sync) ──────────────────────── */
60+
61+
export async function transcribeFile(
62+
appKey: string,
63+
accessKey: string,
64+
file: File,
65+
params: VolcTranscribeParams = {},
66+
signal?: AbortSignal,
67+
): Promise<VolcRecognizeResponse> {
68+
const buffer = await file.arrayBuffer()
69+
const base64Data = arrayBufferToBase64(buffer)
70+
71+
const requestId = crypto.randomUUID()
72+
73+
const headers: Record<string, string> = {
74+
'X-Api-App-Key': appKey,
75+
'X-Api-Access-Key': accessKey,
76+
'X-Api-Resource-Id': 'volc.bigasr.auc_turbo',
77+
'X-Api-Request-Id': requestId,
78+
'X-Api-Sequence': '-1',
79+
'Content-Type': 'application/json',
80+
}
81+
82+
const body = {
83+
user: { uid: appKey },
84+
audio: { data: base64Data },
85+
request: {
86+
model_name: 'bigmodel',
87+
enable_itn: params.enableItn ?? true,
88+
enable_punc: params.enablePunc ?? true,
89+
enable_ddc: params.enableDdc ?? true,
90+
enable_speaker_info: params.enableSpeakerInfo ?? false,
91+
},
92+
}
93+
94+
const res = await fetch(VOLC_RECOGNIZE_URL, {
95+
method: 'POST',
96+
headers,
97+
body: JSON.stringify(body),
98+
signal,
99+
})
100+
101+
const statusCode = res.headers.get('X-Api-Status-Code')
102+
const statusMessage = res.headers.get('X-Api-Message')
103+
104+
if (!res.ok || (statusCode && statusCode !== '20000000')) {
105+
const errMsg = statusMessage || `Volcengine API error (${statusCode || res.status})`
106+
throw new Error(errMsg)
107+
}
108+
109+
return res.json()
110+
}

0 commit comments

Comments
 (0)