From e8babd48b6bcb1fe911474864fefebe9646fe2f3 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:16:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=BD=AC=E6=96=87=E5=AD=97=E5=B9=B6=E6=94=AF=E6=8C=81=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=BE=93=E5=87=BA=EF=BC=9B=20fix:=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=86=E8=AF=AD=E9=9F=B3=E8=A7=A3=E5=AF=86=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 18 +- electron/preload.ts | 10 +- electron/services/analyticsService.ts | 2 +- electron/services/chatService.ts | 295 +++++++++++--- electron/services/contactCacheService.ts | 2 +- electron/services/exportService.ts | 205 +++++++--- electron/services/imageDecryptService.ts | 76 ++-- electron/services/messageCacheService.ts | 2 +- electron/services/voiceTranscribeService.ts | 408 ++++++++++++-------- electron/services/wcdbCore.ts | 35 +- electron/services/wcdbService.ts | 7 + electron/transcribeWorker.ts | 174 +++++++++ electron/types/sherpa-onnx-node.d.ts | 4 + electron/wcdbWorker.ts | 6 + package-lock.json | 194 ++++------ package.json | 10 +- resources/ggml-base.dll | Bin 536064 -> 0 bytes resources/ggml-cpu.dll | Bin 683008 -> 0 bytes resources/ggml.dll | Bin 67584 -> 0 bytes resources/wcdb_api.dll | Bin 585216 -> 603136 bytes resources/whisper-cli.exe | Bin 480768 -> 0 bytes resources/whisper-main.exe | Bin 25088 -> 0 bytes resources/whisper.dll | Bin 483840 -> 0 bytes src/components/AnimatedStreamingText.tsx | 63 +++ src/components/VoiceTranscribeDialog.scss | 255 ++++++++++++ src/components/VoiceTranscribeDialog.tsx | 145 +++++++ src/pages/ChatPage.scss | 28 ++ src/pages/ChatPage.tsx | 130 ++++++- src/pages/ExportPage.tsx | 32 +- src/pages/SettingsPage.tsx | 144 +++---- src/services/config.ts | 14 +- src/types/electron.d.ts | 7 +- vite.config.ts | 17 + 33 files changed, 1713 insertions(+), 570 deletions(-) create mode 100644 electron/transcribeWorker.ts create mode 100644 electron/types/sherpa-onnx-node.d.ts delete mode 100644 resources/ggml-base.dll delete mode 100644 resources/ggml-cpu.dll delete mode 100644 resources/ggml.dll delete mode 100644 resources/whisper-cli.exe delete mode 100644 resources/whisper-main.exe delete mode 100644 resources/whisper.dll create mode 100644 src/components/AnimatedStreamingText.tsx create mode 100644 src/components/VoiceTranscribeDialog.scss create mode 100644 src/components/VoiceTranscribeDialog.tsx diff --git a/electron/main.ts b/electron/main.ts index 2e4038b..15ae142 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -439,12 +439,14 @@ function registerIpcHandlers() { return chatService.getImageData(sessionId, msgId) }) - ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => { - return chatService.getVoiceData(sessionId, msgId) + ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => { + return chatService.getVoiceData(sessionId, msgId, createTime, serverId) }) - ipcMain.handle('chat:getVoiceTranscript', async (_, sessionId: string, msgId: string) => { - return chatService.getVoiceTranscript(sessionId, msgId) + ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => { + return chatService.getVoiceTranscript(sessionId, msgId, (text) => { + event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) + }) }) ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { @@ -521,14 +523,14 @@ function registerIpcHandlers() { return { success: true } }) - ipcMain.handle('whisper:downloadModel', async (event, payload: { modelName: string; downloadDir?: string; source?: string }) => { - return voiceTranscribeService.downloadModel(payload, (progress) => { + ipcMain.handle('whisper:downloadModel', async (event) => { + return voiceTranscribeService.downloadModel((progress) => { event.sender.send('whisper:downloadProgress', progress) }) }) - ipcMain.handle('whisper:getModelStatus', async (_, payload: { modelName: string; downloadDir?: string }) => { - return voiceTranscribeService.getModelStatus(payload) + ipcMain.handle('whisper:getModelStatus', async () => { + return voiceTranscribeService.getModelStatus() }) // 群聊分析相关 diff --git a/electron/preload.ts b/electron/preload.ts index 81fbd1e..0470246 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -106,8 +106,14 @@ contextBridge.exposeInMainWorld('electronAPI', { close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), - getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId), - getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId) + getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => + ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), + getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId), + onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { + const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) + ipcRenderer.on('chat:voiceTranscriptPartial', listener) + return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) + } }, diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 43c1f79..d52508a 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -324,7 +324,7 @@ class AnalyticsService { } private getCacheFilePath(): string { - return join(app.getPath('userData'), 'analytics_cache.json') + return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json') } private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 8953759..cf8679d 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -7,11 +7,7 @@ import * as http from 'http' import * as fzstd from 'fzstd' import * as crypto from 'crypto' import Database from 'better-sqlite3' -import { execFile } from 'child_process' -import { promisify } from 'util' import { app } from 'electron' - -const execFileAsync = promisify(execFile) import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' @@ -2149,7 +2145,107 @@ class ChatService { } } - async getVoiceData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> { + /** + * getVoiceData (优化的 C++ 实现 + 文件缓存) + */ + async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { + + try { + const localId = parseInt(msgId, 10) + if (isNaN(localId)) { + return { success: false, error: '无效的消息ID' } + } + + // 检查文件缓存 + const cacheKey = this.getVoiceCacheKey(sessionId, msgId) + const cachedFile = this.getVoiceCacheFilePath(cacheKey) + if (existsSync(cachedFile)) { + try { + const wavData = readFileSync(cachedFile) + console.info('[ChatService][Voice] 使用缓存文件:', cachedFile) + return { success: true, data: wavData.toString('base64') } + } catch (e) { + console.error('[ChatService][Voice] 读取缓存失败:', e) + // 继续重新解密 + } + } + + // 1. 确定 createTime 和 svrId + let msgCreateTime = createTime + let msgSvrId: string | number = serverId || 0 + + // 如果提供了传来的参数,验证其有效性 + if (!msgCreateTime || msgCreateTime === 0) { + const msgResult = await this.getMessageByLocalId(sessionId, localId) + if (msgResult.success && msgResult.message) { + const msg = msgResult.message as any + msgCreateTime = msg.createTime || msg.create_time + // 尝试获取各种可能的 server id 列名 (只有在没有传入 serverId 时才查找) + if (!msgSvrId || msgSvrId === 0) { + msgSvrId = msg.serverId || msg.svr_id || msg.msg_svr_id || msg.message_id || 0 + } + } + } + + if (!msgCreateTime) { + return { success: false, error: '未找到消息时间戳' } + } + + // 2. 构建查找候选 (sessionId, myWxid) + const candidates: string[] = [] + if (sessionId) candidates.push(sessionId) + const myWxid = this.configService.get('myWxid') as string + if (myWxid && !candidates.includes(myWxid)) { + candidates.push(myWxid) + } + + + + // 3. 调用 C++ 接口获取语音 (Hex) + const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, msgSvrId) + if (!voiceRes.success || !voiceRes.hex) { + return { success: false, error: voiceRes.error || '未找到语音数据' } + } + + + + // 4. Hex 转 Buffer (Silk) + const silkData = Buffer.from(voiceRes.hex, 'hex') + + // 5. 使用 silk-wasm 解码 + try { + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + if (!pcmData) { + return { success: false, error: 'Silk 解码失败' } + } + + // PCM -> WAV + const wavData = this.createWavBuffer(pcmData, 24000) + + // 保存到文件缓存 + try { + this.saveVoiceCache(cacheKey, wavData) + console.info('[ChatService][Voice] 已保存缓存:', cachedFile) + } catch (e) { + console.error('[ChatService][Voice] 保存缓存失败:', e) + // 不影响返回 + } + + // 缓存 WAV 数据 (内存缓存) + this.cacheVoiceWav(cacheKey, wavData) + + return { success: true, data: wavData.toString('base64') } + } catch (e) { + console.error('[ChatService][Voice] decoding error:', e) + return { success: false, error: '语音解码失败: ' + String(e) } + } + } catch (e) { + console.error('ChatService: getVoiceData 失败:', e) + return { success: false, error: String(e) } + } + } + + async getVoiceData_Legacy(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> { try { const localId = parseInt(msgId, 10) const msgResult = await this.getMessageByLocalId(sessionId, localId) @@ -2187,12 +2283,10 @@ class ChatService { for (const dbPath of (mediaDbs.data || [])) { const voiceTable = await this.resolveVoiceInfoTableName(dbPath) if (!voiceTable) { - console.warn('[ChatService][Voice] voice table not found', dbPath) continue } const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable) if (!columns) { - console.warn('[ChatService][Voice] voice columns not found', { dbPath, voiceTable }) continue } for (const candidate of candidates) { @@ -2233,52 +2327,44 @@ class ChatService { } } if (silkData) break + + // 策略 3: 只使用 CreateTime (兜底) + if (!silkData && columns.createTimeColumn) { + const whereClause = `${columns.createTimeColumn} = ${msg.createTime}` + const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` + const result = await wcdbService.execQuery('media', dbPath, sql) + if (result.success && result.rows && result.rows.length > 0) { + const raw = result.rows[0]?.data + const decoded = this.decodeVoiceBlob(raw) + if (decoded && decoded.length > 0) { + console.info('[ChatService][Voice] hit by createTime only', { dbPath, voiceTable, whereClause, bytes: decoded.length }) + silkData = decoded + } + } + } + if (silkData) break } if (!silkData) return { success: false, error: '未找到语音数据' } - // 4. 解码 Silk -> PCM -> WAV - const resourcesPath = app.isPackaged - ? join(process.resourcesPath, 'resources') - : join(app.getAppPath(), 'resources') - const decoderPath = join(resourcesPath, 'silk_v3_decoder.exe') - - if (!existsSync(decoderPath)) { - return { success: false, error: '找不到语音解码器 (silk_v3_decoder.exe)' } - } - console.info('[ChatService][Voice] decoder path', decoderPath) - - const tempDir = app.getPath('temp') - const silkFile = join(tempDir, `voice_${msgId}.silk`) - const pcmFile = join(tempDir, `voice_${msgId}.pcm`) - + // 4. 使用 silk-wasm 解码 try { - writeFileSync(silkFile, silkData) - // 执行解码: silk_v3_decoder.exe -Fs_API 24000 - console.info('[ChatService][Voice] executing decoder:', decoderPath, [silkFile, pcmFile]) - const { stdout, stderr } = await execFileAsync( - decoderPath, - [silkFile, pcmFile, '-Fs_API', '24000'], - { cwd: dirname(decoderPath) } - ) - if (stdout && stdout.trim()) console.info('[ChatService][Voice] decoder stdout:', stdout) - if (stderr && stderr.trim()) console.warn('[ChatService][Voice] decoder stderr:', stderr) - - if (!existsSync(pcmFile)) { - return { success: false, error: '语音解码失败' } + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + if (!pcmData) { + return { success: false, error: 'Silk 解码失败' } } - const pcmData = readFileSync(pcmFile) - const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz - const wavData = Buffer.concat([wavHeader, pcmData]) + // PCM -> WAV + const wavData = this.createWavBuffer(pcmData, 24000) + + // 缓存 WAV 数据 (内存缓存) const cacheKey = this.getVoiceCacheKey(sessionId, msgId) this.cacheVoiceWav(cacheKey, wavData) return { success: true, data: wavData.toString('base64') } - } finally { - // 清理临时文件 - try { if (existsSync(silkFile)) unlinkSync(silkFile) } catch { } - try { if (existsSync(pcmFile)) unlinkSync(pcmFile) } catch { } + } catch (e) { + console.error('[ChatService][Voice] decoding error:', e) + return { success: false, error: '语音解码失败: ' + String(e) } } } catch (e) { console.error('ChatService: getVoiceData 失败:', e) @@ -2286,7 +2372,69 @@ class ChatService { } } - async getVoiceTranscript(sessionId: string, msgId: string): Promise<{ success: boolean; transcript?: string; error?: string }> { + + + /** + * 解码 Silk 数据为 PCM (silk-wasm) + */ + private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { + try { + let wasmPath: string + if (app.isPackaged) { + wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + if (!existsSync(wasmPath)) { + wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + } + } else { + wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + } + + if (!existsSync(wasmPath)) { + console.error('[ChatService][Voice] silk.wasm not found at:', wasmPath) + return null + } + + const silkWasm = require('silk-wasm') + if (!silkWasm || !silkWasm.decode) { + console.error('[ChatService][Voice] silk-wasm module invalid') + return null + } + + const result = await silkWasm.decode(silkData, sampleRate) + return Buffer.from(result.data) + } catch (e) { + console.error('[ChatService][Voice] internal decode error:', e) + return null + } + } + + /** + * 创建 WAV 文件 Buffer + */ + private createWavBuffer(pcmData: Buffer, sampleRate: number = 24000, channels: number = 1): Buffer { + const pcmLength = pcmData.length + const header = Buffer.alloc(44) + header.write('RIFF', 0) + header.writeUInt32LE(36 + pcmLength, 4) + header.write('WAVE', 8) + header.write('fmt ', 12) + header.writeUInt32LE(16, 16) + header.writeUInt16LE(1, 20) + header.writeUInt16LE(channels, 22) + header.writeUInt32LE(sampleRate, 24) + header.writeUInt32LE(sampleRate * channels * 2, 28) + header.writeUInt16LE(channels * 2, 32) + header.writeUInt16LE(16, 34) + header.write('data', 36) + header.writeUInt32LE(pcmLength, 40) + return Buffer.concat([header, pcmData]) + } + + async getVoiceTranscript( + sessionId: string, + msgId: string, + onPartial?: (text: string) => void + ): Promise<{ success: boolean; transcript?: string; error?: string }> { const cacheKey = this.getVoiceCacheKey(sessionId, msgId) const cached = this.voiceTranscriptCache.get(cacheKey) if (cached) { @@ -2302,14 +2450,25 @@ class ChatService { try { let wavData = this.voiceWavCache.get(cacheKey) if (!wavData) { - const voiceResult = await this.getVoiceData(sessionId, msgId) + // 获取消息详情以拿到 createTime 和 serverId + let cTime: number | undefined + let sId: string | number | undefined + const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) + if (msgResult.success && msgResult.message) { + cTime = msgResult.message.createTime + sId = msgResult.message.serverId + } + + const voiceResult = await this.getVoiceData(sessionId, msgId, cTime, sId) if (!voiceResult.success || !voiceResult.data) { return { success: false, error: voiceResult.error || '语音解码失败' } } wavData = Buffer.from(voiceResult.data, 'base64') } - const result = await voiceTranscribeService.transcribeWavBuffer(wavData) + const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { + onPartial?.(text) + }) if (result.success && result.transcript) { this.cacheVoiceTranscript(cacheKey, result.transcript) } @@ -2325,26 +2484,10 @@ class ChatService { return task } - private createWavHeader(pcmLength: number, sampleRate: number = 24000, channels: number = 1): Buffer { - const header = Buffer.alloc(44) - header.write('RIFF', 0) - header.writeUInt32LE(36 + pcmLength, 4) - header.write('WAVE', 8) - header.write('fmt ', 12) - header.writeUInt32LE(16, 16) - header.writeUInt16LE(1, 20) - header.writeUInt16LE(channels, 22) - header.writeUInt32LE(sampleRate, 24) - header.writeUInt32LE(sampleRate * channels * 2, 28) - header.writeUInt16LE(channels * 2, 32) - header.writeUInt16LE(16, 34) - header.write('data', 36) - header.writeUInt32LE(pcmLength, 40) - return header - } + private getVoiceCacheKey(sessionId: string, msgId: string): string { - return `${sessionId}:${msgId}` + return `${sessionId}_${msgId}` } private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { @@ -2355,6 +2498,32 @@ class ChatService { } } + /** + * 获取语音缓存文件路径 + */ + private getVoiceCacheFilePath(cacheKey: string): string { + const cachePath = this.configService.get('cachePath') as string | undefined + let baseDir: string + if (cachePath && cachePath.trim()) { + baseDir = join(cachePath, 'Voices') + } else { + const documentsPath = app.getPath('documents') + baseDir = join(documentsPath, 'WeFlow', 'Voices') + } + if (!existsSync(baseDir)) { + mkdirSync(baseDir, { recursive: true }) + } + return join(baseDir, `${cacheKey}.wav`) + } + + /** + * 保存语音到文件缓存 + */ + private saveVoiceCache(cacheKey: string, wavData: Buffer): void { + const filePath = this.getVoiceCacheFilePath(cacheKey) + writeFileSync(filePath, wavData) + } + private cacheVoiceTranscript(cacheKey: string, transcript: string): void { this.voiceTranscriptCache.set(cacheKey, transcript) if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { diff --git a/electron/services/contactCacheService.ts b/electron/services/contactCacheService.ts index 60f4474..0dad44f 100644 --- a/electron/services/contactCacheService.ts +++ b/electron/services/contactCacheService.ts @@ -15,7 +15,7 @@ export class ContactCacheService { constructor(cacheBasePath?: string) { const basePath = cacheBasePath && cacheBasePath.trim().length > 0 ? cacheBasePath - : join(app.getPath('userData'), 'WeFlowCache') + : join(app.getPath('documents'), 'WeFlow') this.cacheFilePath = join(basePath, 'contacts.json') this.ensureCacheDir() this.loadCache() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index ce572ba..9e985bc 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -70,6 +70,7 @@ export interface ExportOptions { exportImages?: boolean exportVoices?: boolean exportEmojis?: boolean + exportVoiceAsText?: boolean } interface MediaExportItem { @@ -227,6 +228,7 @@ class ExportService { /** * 解析消息内容为可读文本 + * 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理 */ private parseMessageContent(content: string, localType: number): string | null { if (!content) return null @@ -235,7 +237,7 @@ class ExportService { case 1: return this.stripSenderPrefix(content) case 3: return '[图片]' - case 34: return '[语音消息]' + case 34: return '[语音消息]' // 占位符,导出时会替换为转文字结果 case 42: return '[名片]' case 43: return '[视频]' case 47: return '[动画表情]' @@ -246,6 +248,7 @@ class ExportService { } case 50: return this.parseVoipMessage(content) case 10000: return this.cleanSystemMessage(content) + case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 default: if (content.includes('57')) { const title = this.extractXmlValue(content, 'title') @@ -270,20 +273,20 @@ class ExportService { private cleanSystemMessage(content: string): string { if (!content) return '[系统消息]' - + // 先尝试提取特定的系统消息内容 // 1. 提取 sysmsg 中的文本内容 const sysmsgTextMatch = /]*>([\s\S]*?)<\/sysmsg>/i.exec(content) if (sysmsgTextMatch) { content = sysmsgTextMatch[1] } - + // 2. 提取 revokemsg 撤回消息 const revokeMatch = /<\/replacemsg>/i.exec(content) if (revokeMatch) { return revokeMatch[1].trim() } - + // 3. 提取 pat 拍一拍消息 const patMatch = /