mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-06-15 09:27:23 +08:00
feat: 新增 Whisper GPU 加速 + STT 模式切换:CUDA 加速 + 状态管理 + UI 切换 + 进度优化 + API 更新
This commit is contained in:
@@ -1249,6 +1249,162 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话的所有语音消息(用于批量转写)
|
||||
* 复用 getMessages 的查询逻辑,只查询语音消息类型
|
||||
*/
|
||||
async getAllVoiceMessages(
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||
try {
|
||||
if (!this.dbDir) {
|
||||
const connectResult = await this.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
}
|
||||
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||
|
||||
// 使用与 getMessages 相同的方法查找会话对应的表
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
if (dbTablePairs.length === 0) {
|
||||
return { success: false, error: '未找到该会话的消息表' }
|
||||
}
|
||||
|
||||
let allVoiceMessages: Message[] = []
|
||||
|
||||
for (const { db, tableName, dbPath } of dbTablePairs) {
|
||||
try {
|
||||
const hasName2IdTable = this.checkTableExists(db, 'Name2Id')
|
||||
|
||||
// 获取当前用户的 rowid(使用缓存)
|
||||
let myRowId: number | null = null
|
||||
if (myWxid && hasName2IdTable) {
|
||||
const cacheKeyOriginal = `${dbPath}:${myWxid}`
|
||||
const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal)
|
||||
|
||||
if (cachedRowIdOriginal !== undefined) {
|
||||
myRowId = cachedRowIdOriginal
|
||||
} else {
|
||||
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
|
||||
if (row?.rowid) {
|
||||
myRowId = row.rowid
|
||||
this.myRowIdCache.set(cacheKeyOriginal, myRowId)
|
||||
} else if (cleanedMyWxid && cleanedMyWxid !== myWxid) {
|
||||
const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}`
|
||||
const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned)
|
||||
|
||||
if (cachedRowIdCleaned !== undefined) {
|
||||
myRowId = cachedRowIdCleaned
|
||||
} else {
|
||||
const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any
|
||||
myRowId = row2?.rowid ?? null
|
||||
this.myRowIdCache.set(cacheKeyCleaned, myRowId)
|
||||
}
|
||||
} else {
|
||||
this.myRowIdCache.set(cacheKeyOriginal, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有语音消息 (localType = 34)
|
||||
// 检查表结构
|
||||
const columns = db.prepare(`PRAGMA table_info('${tableName}')`).all() as any[]
|
||||
const columnNames = columns.map((c: any) => c.name.toLowerCase())
|
||||
const hasTypeColumn = columnNames.includes('type')
|
||||
const hasLocalTypeColumn = columnNames.includes('local_type')
|
||||
|
||||
// 构建 WHERE 条件
|
||||
let typeCondition = ''
|
||||
if (hasLocalTypeColumn && hasTypeColumn) {
|
||||
typeCondition = '(local_type = 34 OR type = 34)'
|
||||
} else if (hasLocalTypeColumn) {
|
||||
typeCondition = 'local_type = 34'
|
||||
} else if (hasTypeColumn) {
|
||||
typeCondition = 'type = 34'
|
||||
} else {
|
||||
console.warn(`[ChatService] 表 ${tableName} 没有 local_type 或 type 列,跳过`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建完整的 SQL 查询
|
||||
let sql: string
|
||||
let rows: any[]
|
||||
|
||||
if (hasName2IdTable && myRowId !== null) {
|
||||
// 有 Name2Id 表且找到了当前用户的 rowid
|
||||
sql = `SELECT m.*,
|
||||
CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send,
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE ${typeCondition}
|
||||
ORDER BY m.sort_seq DESC`
|
||||
rows = db.prepare(sql).all(myRowId) as any[]
|
||||
} else if (hasName2IdTable) {
|
||||
// 有 Name2Id 表但没找到当前用户的 rowid
|
||||
sql = `SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE ${typeCondition}
|
||||
ORDER BY m.sort_seq DESC`
|
||||
rows = db.prepare(sql).all() as any[]
|
||||
} else {
|
||||
// 没有 Name2Id 表
|
||||
sql = `SELECT * FROM ${tableName}
|
||||
WHERE ${typeCondition}
|
||||
ORDER BY sort_seq DESC`
|
||||
rows = db.prepare(sql).all() as any[]
|
||||
}
|
||||
|
||||
// 处理查询结果
|
||||
for (const row of rows) {
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = row.local_type || row.type || 1
|
||||
const isSend = row.computed_is_send ?? row.is_send ?? null
|
||||
const voiceDuration = this.parseVoiceDuration(content)
|
||||
|
||||
allVoiceMessages.push({
|
||||
localId: row.local_id || 0,
|
||||
serverId: row.server_id || 0,
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent: '',
|
||||
rawContent: content,
|
||||
voiceDuration
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 sort_seq 降序排序
|
||||
allVoiceMessages.sort((a, b) => b.sortSeq - a.sortSeq)
|
||||
|
||||
// 去重
|
||||
const seen = new Set<string>()
|
||||
allVoiceMessages = allVoiceMessages.filter(msg => {
|
||||
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
console.log(`[ChatService] 共找到 ${allVoiceMessages.length} 条语音消息(去重后)`)
|
||||
|
||||
return { success: true, messages: allVoiceMessages }
|
||||
} catch (e) {
|
||||
console.error('[ChatService] 获取所有语音消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据日期获取消息(用于日期跳转)
|
||||
* @param sessionId 会话ID
|
||||
@@ -1575,6 +1731,14 @@ class ChatService extends EventEmitter {
|
||||
default:
|
||||
// 对于未知的 localType,检查 XML type 来判断消息类型
|
||||
if (xmlType) {
|
||||
// type=87 群公告消息
|
||||
if (xmlType === '87') {
|
||||
const textAnnouncement = this.extractXmlValue(content, 'textannouncement')
|
||||
if (textAnnouncement) {
|
||||
return `[群公告] ${textAnnouncement}`
|
||||
}
|
||||
return '[群公告]'
|
||||
}
|
||||
// 如果有 XML type,尝试按 type 49 的逻辑解析
|
||||
if (xmlType === '2000' || xmlType === '5' || xmlType === '6' || xmlType === '19' ||
|
||||
xmlType === '33' || xmlType === '36' || xmlType === '49' || xmlType === '57') {
|
||||
@@ -1598,6 +1762,15 @@ class ChatService extends EventEmitter {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
|
||||
// 群公告消息(type 87)特殊处理
|
||||
if (type === '87') {
|
||||
const textAnnouncement = this.extractXmlValue(content, 'textannouncement')
|
||||
if (textAnnouncement) {
|
||||
return `[群公告] ${textAnnouncement}`
|
||||
}
|
||||
return '[群公告]'
|
||||
}
|
||||
|
||||
// 转账消息特殊处理
|
||||
if (type === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
|
||||
@@ -36,6 +36,8 @@ interface ConfigSchema {
|
||||
// STT 相关
|
||||
sttLanguages: string[]
|
||||
sttModelType: 'int8' | 'float32'
|
||||
sttMode: 'cpu' | 'gpu' // STT 模式:CPU (SenseVoice) 或 GPU (Whisper)
|
||||
whisperModelType: 'tiny' | 'base' | 'small' | 'medium' // Whisper 模型类型
|
||||
|
||||
// 日志相关
|
||||
logLevel: string
|
||||
@@ -79,6 +81,8 @@ const defaults: ConfigSchema = {
|
||||
language: 'zh-CN',
|
||||
sttLanguages: ['zh'],
|
||||
sttModelType: 'int8',
|
||||
sttMode: 'cpu', // 默认使用 CPU 模式
|
||||
whisperModelType: 'small', // 默认使用 small 模型
|
||||
agreementVersion: 0,
|
||||
activationData: '',
|
||||
logLevel: 'WARN', // 默认只记录警告和错误
|
||||
|
||||
@@ -507,6 +507,15 @@ class ExportService {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
|
||||
// 群公告消息(type 87)
|
||||
if (type === '87') {
|
||||
const textAnnouncement = this.extractXmlValue(content, 'textannouncement')
|
||||
if (textAnnouncement) {
|
||||
return `[群公告] ${textAnnouncement}`
|
||||
}
|
||||
return '[群公告]'
|
||||
}
|
||||
|
||||
// 转账消息特殊处理
|
||||
if (type === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
@@ -536,6 +545,15 @@ class ExportService {
|
||||
if (xmlType) {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
|
||||
// 群公告消息(type 87)
|
||||
if (xmlType === '87') {
|
||||
const textAnnouncement = this.extractXmlValue(content, 'textannouncement')
|
||||
if (textAnnouncement) {
|
||||
return `[群公告] ${textAnnouncement}`
|
||||
}
|
||||
return '[群公告]'
|
||||
}
|
||||
|
||||
// 转账消息
|
||||
if (xmlType === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
@@ -1239,6 +1257,7 @@ class ExportService {
|
||||
|
||||
if (xmlType) {
|
||||
switch (xmlType) {
|
||||
case '87': return '群公告'
|
||||
case '2000': return '转账消息'
|
||||
case '5': return '链接消息'
|
||||
case '6': return '文件消息'
|
||||
|
||||
@@ -45,32 +45,83 @@ const MODELS: Record<string, ModelConfig> = {
|
||||
size: 1_500_000_000,
|
||||
sizeLabel: '1.5 GB',
|
||||
quality: '很好'
|
||||
},
|
||||
'large-v3': {
|
||||
name: 'large-v3',
|
||||
filename: 'ggml-large-v3.bin',
|
||||
size: 3_100_000_000,
|
||||
sizeLabel: '3.1 GB',
|
||||
quality: '极好'
|
||||
},
|
||||
'large-v3-turbo': {
|
||||
name: 'large-v3-turbo',
|
||||
filename: 'ggml-large-v3-turbo.bin',
|
||||
size: 1_620_000_000,
|
||||
sizeLabel: '1.62 GB',
|
||||
quality: '极好(推荐)'
|
||||
},
|
||||
'large-v3-turbo-q5': {
|
||||
name: 'large-v3-turbo-q5',
|
||||
filename: 'ggml-large-v3-turbo-q5_0.bin',
|
||||
size: 540_000_000,
|
||||
sizeLabel: '540 MB',
|
||||
quality: '极好(量化版)'
|
||||
},
|
||||
'large-v3-turbo-q8': {
|
||||
name: 'large-v3-turbo-q8',
|
||||
filename: 'ggml-large-v3-turbo-q8_0.bin',
|
||||
size: 835_000_000,
|
||||
sizeLabel: '835 MB',
|
||||
quality: '极好(高质量量化)'
|
||||
}
|
||||
}
|
||||
|
||||
export class VoiceTranscribeServiceWhisper {
|
||||
private modelsDir: string
|
||||
private whisperExe: string
|
||||
private whisperDir: string
|
||||
private useGPU: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.modelsDir = join(app.getPath('appData'), 'ciphertalk', 'whisper-models')
|
||||
|
||||
// whisper.cpp 的可执行文件路径
|
||||
// 生产环境:app.asar.unpacked/resources/whisper/main.exe
|
||||
// 开发环境:项目根目录/resources/whisper/main.exe
|
||||
this.whisperExe = join(process.resourcesPath, 'resources', 'whisper', 'main.exe')
|
||||
let resourcesPath: string
|
||||
|
||||
// 开发模式回退
|
||||
if (!existsSync(this.whisperExe)) {
|
||||
this.whisperExe = join(__dirname, '..', '..', 'resources', 'whisper', 'main.exe')
|
||||
if (app.isPackaged) {
|
||||
resourcesPath = join(process.resourcesPath, 'resources', 'whisper')
|
||||
} else {
|
||||
resourcesPath = join(app.getAppPath(), 'resources', 'whisper')
|
||||
}
|
||||
|
||||
const cliExe = join(resourcesPath, 'whisper-cli.exe')
|
||||
const mainExe = join(resourcesPath, 'main.exe')
|
||||
|
||||
this.whisperExe = existsSync(cliExe) ? cliExe : mainExe
|
||||
this.whisperDir = resourcesPath
|
||||
|
||||
if (!existsSync(this.modelsDir)) {
|
||||
mkdirSync(this.modelsDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 GPU 组件目录(从用户配置的缓存目录)
|
||||
*/
|
||||
setGPUComponentsDir(cachePath: string) {
|
||||
const gpuDir = join(cachePath, 'whisper-gpu')
|
||||
if (!existsSync(gpuDir)) {
|
||||
mkdirSync(gpuDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 检查用户缓存目录是否有完整的 GPU 组件
|
||||
const gpuExe = join(gpuDir, 'whisper-cli.exe')
|
||||
if (existsSync(gpuExe)) {
|
||||
this.whisperExe = gpuExe
|
||||
this.whisperDir = gpuDir
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 GPU 支持
|
||||
*/
|
||||
@@ -80,22 +131,59 @@ export class VoiceTranscribeServiceWhisper {
|
||||
info: string
|
||||
}> {
|
||||
try {
|
||||
if (!existsSync(this.whisperExe)) {
|
||||
return {
|
||||
available: false,
|
||||
provider: 'CPU',
|
||||
info: 'Whisper 可执行文件不存在'
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 NVIDIA GPU
|
||||
const { execSync } = require('child_process')
|
||||
try {
|
||||
const output = execSync('nvidia-smi --query-gpu=name --format=csv,noheader', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000
|
||||
timeout: 3000,
|
||||
windowsHide: true
|
||||
})
|
||||
if (output.trim()) {
|
||||
this.useGPU = true
|
||||
return {
|
||||
available: true,
|
||||
provider: 'CUDA',
|
||||
info: `检测到 GPU: ${output.trim()}`
|
||||
|
||||
const gpuName = output.trim()
|
||||
|
||||
if (gpuName) {
|
||||
// 检查是否有 CUDA DLL
|
||||
const cudaDll = join(this.whisperDir, 'ggml-cuda.dll')
|
||||
|
||||
if (existsSync(cudaDll)) {
|
||||
this.useGPU = true
|
||||
return {
|
||||
available: true,
|
||||
provider: 'NVIDIA CUDA',
|
||||
info: `GPU: ${gpuName} (支持 CUDA 加速)`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
available: false,
|
||||
provider: 'CPU',
|
||||
info: `检测到 ${gpuName},但缺少 CUDA 支持文件`
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
// nvidia-smi 命令失败,继续检查 CPU 模式
|
||||
}
|
||||
|
||||
// 检查是否有 CPU DLL
|
||||
const cpuDll = join(this.whisperDir, 'ggml-cpu.dll')
|
||||
|
||||
if (existsSync(cpuDll)) {
|
||||
this.useGPU = false
|
||||
return {
|
||||
available: false,
|
||||
provider: 'CPU',
|
||||
info: '未检测到 NVIDIA GPU,将使用 CPU 模式(仍比 SenseVoice 快)'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: false,
|
||||
@@ -103,10 +191,11 @@ export class VoiceTranscribeServiceWhisper {
|
||||
info: 'GPU 不可用,将使用 CPU'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Whisper] GPU 检测失败:', error)
|
||||
return {
|
||||
available: false,
|
||||
provider: 'CPU',
|
||||
info: 'GPU 检测失败,使用 CPU'
|
||||
info: `GPU 检测失败: ${error}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,13 +223,32 @@ export class VoiceTranscribeServiceWhisper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定模型
|
||||
*/
|
||||
async clearModel(modelType: keyof typeof MODELS = 'small'): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const config = MODELS[modelType]
|
||||
const modelPath = join(this.modelsDir, config.filename)
|
||||
|
||||
if (existsSync(modelPath)) {
|
||||
unlinkSync(modelPath)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[Whisper] 清除模型失败:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转文字
|
||||
*/
|
||||
async transcribeWavBuffer(
|
||||
wavData: Buffer,
|
||||
modelType: keyof typeof MODELS = 'small',
|
||||
language: string = 'zh'
|
||||
language: string = 'auto'
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
const config = MODELS[modelType]
|
||||
const modelPath = join(this.modelsDir, config.filename)
|
||||
@@ -156,10 +264,14 @@ export class VoiceTranscribeServiceWhisper {
|
||||
}
|
||||
}
|
||||
|
||||
let tempWavPath: string | null = null
|
||||
let txtPath: string | null = null
|
||||
|
||||
try {
|
||||
// 保存临时 WAV 文件
|
||||
const tempWavPath = join(app.getPath('temp'), `whisper_${Date.now()}.wav`)
|
||||
tempWavPath = join(app.getPath('temp'), `whisper_${Date.now()}.wav`)
|
||||
writeFileSync(tempWavPath, wavData)
|
||||
txtPath = tempWavPath + '.txt'
|
||||
|
||||
// 构建命令参数
|
||||
const args = [
|
||||
@@ -167,37 +279,36 @@ export class VoiceTranscribeServiceWhisper {
|
||||
'-f', tempWavPath,
|
||||
'-l', language,
|
||||
'-t', '4', // 线程数
|
||||
'--no-timestamps', // 不输出时间戳
|
||||
'--output-txt' // 输出文本
|
||||
'-nt', // 不输出时间戳
|
||||
'-otxt' // 输出文本到 .txt 文件
|
||||
]
|
||||
|
||||
// 如果支持 GPU,添加 GPU 参数
|
||||
if (this.useGPU) {
|
||||
args.push('-ng') // 使用 GPU
|
||||
}
|
||||
|
||||
console.log('[Whisper] 开始识别...')
|
||||
console.log('[Whisper] 模型:', modelType)
|
||||
console.log('[Whisper] 语言:', language)
|
||||
console.log('[Whisper] GPU:', this.useGPU ? '启用' : '禁用')
|
||||
// 注意:-ng 是 "no-gpu",我们不加这个参数就会自动使用 GPU
|
||||
// 如果不想用 GPU,才加 -ng
|
||||
|
||||
// 执行 whisper
|
||||
const result = await this.runWhisper(args)
|
||||
|
||||
// 清理临时文件
|
||||
try {
|
||||
unlinkSync(tempWavPath)
|
||||
const txtPath = tempWavPath + '.txt'
|
||||
if (existsSync(txtPath)) {
|
||||
unlinkSync(txtPath)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Whisper] 清理临时文件失败:', e)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
console.log('[Whisper] 识别成功')
|
||||
return { success: true, transcript: result.text }
|
||||
// 优先从 .txt 文件读取结果
|
||||
let transcript = ''
|
||||
|
||||
if (existsSync(txtPath)) {
|
||||
const { readFileSync } = require('fs')
|
||||
transcript = readFileSync(txtPath, 'utf-8').trim()
|
||||
}
|
||||
|
||||
// 如果 .txt 文件为空,尝试从 stdout 提取
|
||||
if (!transcript && result.text) {
|
||||
transcript = result.text
|
||||
}
|
||||
|
||||
if (transcript) {
|
||||
return { success: true, transcript }
|
||||
} else {
|
||||
console.error('[Whisper] 识别结果为空')
|
||||
return { success: false, error: '识别结果为空' }
|
||||
}
|
||||
} else {
|
||||
console.error('[Whisper] 识别失败:', result.error)
|
||||
return { success: false, error: result.error }
|
||||
@@ -205,6 +316,18 @@ export class VoiceTranscribeServiceWhisper {
|
||||
} catch (error) {
|
||||
console.error('[Whisper] 异常:', error)
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
try {
|
||||
if (tempWavPath && existsSync(tempWavPath)) {
|
||||
unlinkSync(tempWavPath)
|
||||
}
|
||||
if (txtPath && existsSync(txtPath)) {
|
||||
unlinkSync(txtPath)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Whisper] 清理临时文件失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,15 +371,35 @@ export class VoiceTranscribeServiceWhisper {
|
||||
* 从输出中提取文本
|
||||
*/
|
||||
private extractText(output: string): string {
|
||||
// whisper.cpp 的输出格式:[时间戳] 文本
|
||||
// whisper.cpp 的输出格式有多种可能:
|
||||
// 1. 带时间戳: [00:00:00.000 --> 00:00:05.000] 文本
|
||||
// 2. 不带时间戳(-nt): 直接输出文本
|
||||
const lines = output.split('\n')
|
||||
const textLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// 匹配 [00:00:00.000 --> 00:00:05.000] 文本
|
||||
const match = line.match(/\[[\d:.]+\s+-->\s+[\d:.]+\]\s+(.+)/)
|
||||
if (match) {
|
||||
textLines.push(match[1].trim())
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
// 跳过日志行
|
||||
if (trimmed.startsWith('[') && !trimmed.includes('-->')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 匹配带时间戳的格式: [00:00:00.000 --> 00:00:05.000] 文本
|
||||
const timestampMatch = trimmed.match(/\[[\d:.]+\s+-->\s+[\d:.]+\]\s+(.+)/)
|
||||
if (timestampMatch) {
|
||||
textLines.push(timestampMatch[1].trim())
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果不是日志行且不为空,直接作为文本
|
||||
if (!trimmed.startsWith('whisper_') &&
|
||||
!trimmed.startsWith('system_info:') &&
|
||||
!trimmed.includes('processing') &&
|
||||
!trimmed.includes('load time') &&
|
||||
trimmed.length > 0) {
|
||||
textLines.push(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +407,7 @@ export class VoiceTranscribeServiceWhisper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载模型
|
||||
* 下载模型(使用 GGML 格式)
|
||||
*/
|
||||
async downloadModel(
|
||||
modelType: keyof typeof MODELS,
|
||||
@@ -274,8 +417,8 @@ export class VoiceTranscribeServiceWhisper {
|
||||
const config = MODELS[modelType]
|
||||
const modelPath = join(this.modelsDir, config.filename)
|
||||
|
||||
// 从 Hugging Face 下载
|
||||
const url = `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${config.filename}`
|
||||
// 使用 ModelScope iceCream2025 仓库(已验证可用)
|
||||
const url = `https://modelscope.cn/models/iceCream2025/whisper.cpp/resolve/master/${config.filename}`
|
||||
|
||||
await this.downloadFile(url, modelPath, (downloaded, total) => {
|
||||
const percent = total ? (downloaded / total) * 100 : undefined
|
||||
@@ -285,7 +428,6 @@ export class VoiceTranscribeServiceWhisper {
|
||||
percent
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[Whisper] 下载失败:', error)
|
||||
@@ -294,13 +436,14 @@ export class VoiceTranscribeServiceWhisper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* 下载文件(支持重定向和超时重试)
|
||||
*/
|
||||
private downloadFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
onProgress?: (downloaded: number, total?: number) => void,
|
||||
remainingRedirects = 5
|
||||
remainingRedirects = 5,
|
||||
timeout = 30000 // 30秒超时
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
@@ -308,7 +451,8 @@ export class VoiceTranscribeServiceWhisper {
|
||||
const request = protocol.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
},
|
||||
timeout
|
||||
}, (response) => {
|
||||
// 处理重定向
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
||||
@@ -317,7 +461,7 @@ export class VoiceTranscribeServiceWhisper {
|
||||
return
|
||||
}
|
||||
|
||||
this.downloadFile(response.headers.location, targetPath, onProgress, remainingRedirects - 1)
|
||||
this.downloadFile(response.headers.location, targetPath, onProgress, remainingRedirects - 1, timeout)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
return
|
||||
@@ -349,6 +493,10 @@ export class VoiceTranscribeServiceWhisper {
|
||||
})
|
||||
|
||||
request.on('error', reject)
|
||||
request.on('timeout', () => {
|
||||
request.destroy()
|
||||
reject(new Error('下载超时'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user