Skip to content

Commit 0f7dc66

Browse files
committed
feat: add support for Volcano Doubao STT provider and update related configurations
- Added Volcano Doubao as a new online STT provider in the application. - Updated the configuration schema to include Volcano Doubao as a valid provider option. - Implemented the Volcano Doubao STT provider with necessary API integration. - Modified existing STT handling code to accommodate the new provider. - Updated UI components to reflect the addition of Volcano Doubao in settings and options. - Enhanced type definitions to include Volcano Doubao in relevant interfaces.
1 parent 1c9e70d commit 0f7dc66

15 files changed

Lines changed: 551 additions & 338 deletions

File tree

electron/main/ipc/sttHandlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export function registerSttHandlers(ctx: MainProcessContext): void {
113113
})
114114

115115
ipcMain.handle('stt-online:test-config', async (_, overrides?: {
116-
provider?: 'openai-compatible' | 'aliyun-qwen-asr' | 'qianwen-cloud' | 'custom'
116+
provider?: 'openai-compatible' | 'aliyun-qwen-asr' | 'qianwen-cloud' | 'volcano-doubao' | 'custom'
117117
apiKey?: string
118118
baseURL?: string
119119
model?: string

electron/preload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
760760
cancelDownloadModel: () => ipcRenderer.invoke('stt:cancelDownloadModel'),
761761
transcribe: (wavBase64: string, sessionId: string, createTime: number, force?: boolean) => ipcRenderer.invoke('stt:transcribe', wavBase64, sessionId, createTime, force),
762762
transcribeAudioFile: (filePath: string) => ipcRenderer.invoke('stt:transcribeAudioFile', filePath),
763-
testOnlineConfig: (overrides?: { provider?: 'openai-compatible' | 'aliyun-qwen-asr' | 'qianwen-cloud' | 'custom'; apiKey?: string; baseURL?: string; model?: string; language?: string; timeoutMs?: number }) =>
763+
testOnlineConfig: (overrides?: { provider?: 'openai-compatible' | 'aliyun-qwen-asr' | 'qianwen-cloud' | 'volcano-doubao' | 'custom'; apiKey?: string; baseURL?: string; model?: string; language?: string; timeoutMs?: number }) =>
764764
ipcRenderer.invoke('stt-online:test-config', overrides),
765765
onDownloadProgress: (callback: (progress: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
766766
ipcRenderer.on('stt:downloadProgress', (_, progress) => callback(progress))

electron/services/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ interface ConfigSchema {
8080
sttModelType: 'int8' | 'float32'
8181
sttMode: 'cpu' | 'gpu' | 'online' // STT 模式:CPU / GPU / 在线
8282
whisperModelType: 'tiny' | 'base' | 'small' | 'medium' // Whisper 模型类型
83-
sttOnlineProvider: 'openai-compatible' | 'aliyun-qwen-asr' | 'qianwen-cloud' | 'custom'
83+
sttOnlineProvider: 'openai-compatible' | 'aliyun-qwen-asr' | 'qianwen-cloud' | 'volcano-doubao' | 'custom'
8484
sttOnlineApiKey: string
8585
sttOnlineBaseURL: string
8686
sttOnlineModel: string
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { OnlineSttProvider, OnlineTranscribeConfig, TranscribeResult, TestResult } from '../types'
2+
import { maskKey, resolveAliyunChatUrl, resolveModelsUrl } from '../urls'
3+
4+
// 阿里云 DashScope 兼容入口(qwen3-asr-flash 等),千问云同端点共用此实现。
5+
// 走 /chat/completions + input_audio 流式(SSE),逐 chunk 拼接识别文本。
6+
7+
function extractTextFromContent(content: any): string {
8+
if (!content) return ''
9+
if (typeof content === 'string') return content
10+
if (Array.isArray(content)) {
11+
return content
12+
.map((item) => {
13+
if (typeof item === 'string') return item
14+
return item?.text || item?.transcript || item?.content || ''
15+
})
16+
.join('')
17+
}
18+
return String(content?.text || content?.transcript || content?.content || '')
19+
}
20+
21+
async function transcribe(
22+
wavData: Buffer,
23+
config: OnlineTranscribeConfig,
24+
signal: AbortSignal,
25+
onPartial?: (text: string) => void
26+
): Promise<TranscribeResult> {
27+
const dataUrl = `data:audio/wav;base64,${wavData.toString('base64')}`
28+
const requestUrl = resolveAliyunChatUrl(config.baseURL)
29+
console.log('[STT-Online][Aliyun] 发起转写请求', {
30+
provider: config.provider,
31+
url: requestUrl,
32+
model: config.model,
33+
apiKey: maskKey(config.apiKey),
34+
audioBytes: wavData.length
35+
})
36+
37+
const response = await fetch(requestUrl, {
38+
method: 'POST',
39+
headers: {
40+
Authorization: `Bearer ${config.apiKey}`,
41+
'Content-Type': 'application/json'
42+
},
43+
body: JSON.stringify({
44+
model: config.model,
45+
stream: true,
46+
messages: [
47+
{
48+
role: 'user',
49+
content: [
50+
{
51+
type: 'input_audio',
52+
input_audio: {
53+
data: dataUrl,
54+
format: 'wav'
55+
}
56+
}
57+
]
58+
}
59+
]
60+
}),
61+
signal
62+
})
63+
64+
console.log('[STT-Online][Aliyun] 响应状态', response.status, response.statusText)
65+
66+
if (!response.ok) {
67+
let rawBody = ''
68+
try {
69+
rawBody = await response.text()
70+
} catch {
71+
rawBody = ''
72+
}
73+
let payload: any = null
74+
try {
75+
payload = rawBody ? JSON.parse(rawBody) : null
76+
} catch {
77+
payload = null
78+
}
79+
80+
console.error('[STT-Online][Aliyun] 转写失败', {
81+
status: response.status,
82+
url: requestUrl,
83+
model: config.model,
84+
body: rawBody || '(空响应体)'
85+
})
86+
87+
const serverMessage = payload?.error?.message || payload?.message || rawBody?.slice(0, 300)
88+
89+
if (response.status === 401) {
90+
return {
91+
success: false,
92+
error: serverMessage ? `在线转写认证失败:${serverMessage}` : '在线转写认证失败,请检查 API Key'
93+
}
94+
}
95+
if (response.status === 403) {
96+
// 403 多为额度耗尽或无该模型权限,而非 Key 错误
97+
return {
98+
success: false,
99+
error: serverMessage
100+
? `在线转写被拒绝 (403):${serverMessage}`
101+
: '在线转写被拒绝 (403),可能是免费额度耗尽或无该模型权限,请到控制台检查'
102+
}
103+
}
104+
if (response.status === 429) {
105+
return { success: false, error: '阿里云在线转写请求过于频繁或额度不足,请稍后重试' }
106+
}
107+
const message = serverMessage || `HTTP ${response.status}`
108+
return { success: false, error: `阿里云在线转写失败: ${message}` }
109+
}
110+
111+
if (!response.body) {
112+
return { success: false, error: '阿里云在线转写未返回可读取的数据流' }
113+
}
114+
115+
const reader = response.body.getReader()
116+
const decoder = new TextDecoder('utf-8')
117+
let buffer = ''
118+
let transcript = ''
119+
120+
while (true) {
121+
const { done, value } = await reader.read()
122+
if (done) break
123+
124+
buffer += decoder.decode(value, { stream: true })
125+
const events = buffer.split('\n\n')
126+
buffer = events.pop() || ''
127+
128+
for (const event of events) {
129+
const dataLines = event
130+
.split('\n')
131+
.map((line) => line.trim())
132+
.filter((line) => line.startsWith('data:'))
133+
134+
for (const line of dataLines) {
135+
const data = line.slice(5).trim()
136+
if (!data || data === '[DONE]') continue
137+
138+
try {
139+
const chunk = JSON.parse(data)
140+
const delta = chunk?.choices?.[0]?.delta
141+
const text = extractTextFromContent(delta?.content)
142+
if (text) {
143+
transcript += text
144+
onPartial?.(transcript)
145+
}
146+
} catch {
147+
// ignore malformed chunk
148+
}
149+
}
150+
}
151+
}
152+
153+
transcript = transcript.trim()
154+
if (!transcript) {
155+
return { success: false, error: '阿里云接口返回成功,但未提取到识别文本' }
156+
}
157+
158+
return { success: true, transcript }
159+
}
160+
161+
async function test(config: OnlineTranscribeConfig, signal: AbortSignal): Promise<TestResult> {
162+
const response = await fetch(resolveModelsUrl(config.baseURL), {
163+
method: 'GET',
164+
headers: { Authorization: `Bearer ${config.apiKey}` },
165+
signal
166+
})
167+
168+
if (response.ok) {
169+
return { success: true }
170+
}
171+
if (response.status === 401 || response.status === 403) {
172+
return { success: false, error: '在线转写认证失败,请检查 API Key' }
173+
}
174+
if (response.status === 404) {
175+
return { success: false, error: '接口 URL 不可用,请确认是否为 DashScope 兼容入口地址' }
176+
}
177+
return { success: false, error: `在线转写配置测试失败: HTTP ${response.status}` }
178+
}
179+
180+
export const aliyunProvider: OnlineSttProvider = { transcribe, test }
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { OnlineSttProvider, OnlineTranscribeConfig, TranscribeResult, TestResult } from '../types'
2+
import { resolveModelsUrl, resolveTranscriptionUrl } from '../urls'
3+
4+
// OpenAI 兼容的 /audio/transcriptions(multipart 上传);'custom' 直接按用户填写的完整 URL 请求。
5+
// 两者共用同一上传/解析逻辑,仅 URL 解析与测试容错策略不同。
6+
7+
function resolveRequestUrl(config: OnlineTranscribeConfig): string {
8+
return config.provider === 'custom' ? config.baseURL.trim() : resolveTranscriptionUrl(config.baseURL)
9+
}
10+
11+
function resolveTestUrl(config: OnlineTranscribeConfig): string {
12+
return config.provider === 'custom' ? config.baseURL.trim() : resolveModelsUrl(config.baseURL)
13+
}
14+
15+
async function transcribe(
16+
wavData: Buffer,
17+
config: OnlineTranscribeConfig,
18+
signal: AbortSignal
19+
): Promise<TranscribeResult> {
20+
const form = new FormData()
21+
const file = new Blob([new Uint8Array(wavData)], { type: 'audio/wav' })
22+
form.append('file', file, 'voice.wav')
23+
form.append('model', config.model)
24+
if (config.language && config.language !== 'auto') {
25+
form.append('language', config.language)
26+
}
27+
form.append('response_format', 'json')
28+
29+
const response = await fetch(resolveRequestUrl(config), {
30+
method: 'POST',
31+
headers: { Authorization: `Bearer ${config.apiKey}` },
32+
body: form,
33+
signal
34+
})
35+
36+
let payload: any = null
37+
try {
38+
payload = await response.json()
39+
} catch {
40+
payload = null
41+
}
42+
43+
if (!response.ok) {
44+
if (response.status === 401 || response.status === 403) {
45+
return { success: false, error: '在线转写认证失败,请检查 API Key' }
46+
}
47+
if (response.status === 429) {
48+
return { success: false, error: '在线转写请求过于频繁或额度不足,请稍后重试' }
49+
}
50+
const message = payload?.error?.message || payload?.message || `HTTP ${response.status}`
51+
return { success: false, error: `在线转写失败: ${message}` }
52+
}
53+
54+
const transcript = String(payload?.text || payload?.transcript || '').trim()
55+
if (!transcript) {
56+
return { success: false, error: '在线转写成功但未返回文本结果' }
57+
}
58+
return { success: true, transcript }
59+
}
60+
61+
async function test(config: OnlineTranscribeConfig, signal: AbortSignal): Promise<TestResult> {
62+
const response = await fetch(resolveTestUrl(config), {
63+
method: 'GET',
64+
headers: { Authorization: `Bearer ${config.apiKey}` },
65+
signal
66+
})
67+
68+
if (response.ok) {
69+
return { success: true }
70+
}
71+
if (response.status === 401 || response.status === 403) {
72+
return { success: false, error: '在线转写认证失败,请检查 API Key' }
73+
}
74+
// 自定义接口对 GET 可能返回这些状态码,但说明地址可达
75+
if (config.provider === 'custom' && [400, 405, 415].includes(response.status)) {
76+
return { success: true }
77+
}
78+
if (response.status === 404) {
79+
return {
80+
success: false,
81+
error:
82+
config.provider === 'custom'
83+
? '自定义接口 URL 不可用,请确认你填写的是完整接口地址'
84+
: '接口 URL 不可用,请确认它是否为 OpenAI 兼容接口或对应的 /v1 地址'
85+
}
86+
}
87+
return { success: false, error: `在线转写配置测试失败: HTTP ${response.status}` }
88+
}
89+
90+
export const openaiCompatibleProvider: OnlineSttProvider = { transcribe, test }

0 commit comments

Comments
 (0)