mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat: 实现语音转文字并支持流式输出;
fix: 修复了语音解密失败的问题
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
// 群聊分析相关
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 <silk> <pcm> -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<Buffer | null> {
|
||||
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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('<type>57</type>')) {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
@@ -270,20 +273,20 @@ class ExportService {
|
||||
|
||||
private cleanSystemMessage(content: string): string {
|
||||
if (!content) return '[系统消息]'
|
||||
|
||||
|
||||
// 先尝试提取特定的系统消息内容
|
||||
// 1. 提取 sysmsg 中的文本内容
|
||||
const sysmsgTextMatch = /<sysmsg[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
|
||||
if (sysmsgTextMatch) {
|
||||
content = sysmsgTextMatch[1]
|
||||
}
|
||||
|
||||
|
||||
// 2. 提取 revokemsg 撤回消息
|
||||
const revokeMatch = /<replacemsg><!\[CDATA\[(.*?)\]\]><\/replacemsg>/i.exec(content)
|
||||
if (revokeMatch) {
|
||||
return revokeMatch[1].trim()
|
||||
}
|
||||
|
||||
|
||||
// 3. 提取 pat 拍一拍消息
|
||||
const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content)
|
||||
if (patMatch) {
|
||||
@@ -296,10 +299,10 @@ class ExportService {
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
|
||||
// 4. 处理 CDATA 内容
|
||||
content = content.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '')
|
||||
|
||||
|
||||
// 5. 移除所有 XML 标签
|
||||
return content
|
||||
.replace(/<img[^>]*>/gi, '')
|
||||
@@ -406,10 +409,10 @@ class ExportService {
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaDir: string,
|
||||
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean }
|
||||
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
|
||||
): Promise<MediaExportItem | null> {
|
||||
const localType = msg.localType
|
||||
|
||||
|
||||
// 图片消息
|
||||
if (localType === 3 && options.exportImages) {
|
||||
const result = await this.exportImage(msg, sessionId, mediaDir)
|
||||
@@ -418,12 +421,19 @@ class ExportService {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// 语音消息
|
||||
if (localType === 34 && options.exportVoices) {
|
||||
return this.exportVoice(msg, sessionId, mediaDir)
|
||||
if (localType === 34) {
|
||||
// 如果开启了语音转文字,优先转文字(不导出语音文件)
|
||||
if (options.exportVoiceAsText) {
|
||||
return null // 转文字逻辑在消息内容处理中完成
|
||||
}
|
||||
// 否则导出语音文件
|
||||
if (options.exportVoices) {
|
||||
return this.exportVoice(msg, sessionId, mediaDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 动画表情
|
||||
if (localType === 47 && options.exportEmojis) {
|
||||
const result = await this.exportEmoji(msg, sessionId, mediaDir)
|
||||
@@ -432,7 +442,7 @@ class ExportService {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -449,7 +459,7 @@ class ExportService {
|
||||
// 使用消息对象中已提取的字段
|
||||
const imageMd5 = msg.imageMd5
|
||||
const imageDatName = msg.imageDatName
|
||||
|
||||
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
console.log('[ExportService] 图片消息缺少 md5 和 datName:', msg.localId)
|
||||
return null
|
||||
@@ -485,9 +495,9 @@ class ExportService {
|
||||
const ext = this.getExtFromDataUrl(sourcePath)
|
||||
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
||||
const destPath = path.join(imagesDir, fileName)
|
||||
|
||||
|
||||
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
||||
|
||||
|
||||
return {
|
||||
relativePath: `media/images/${fileName}`,
|
||||
kind: 'image'
|
||||
@@ -501,11 +511,11 @@ class ExportService {
|
||||
const ext = path.extname(sourcePath) || '.jpg'
|
||||
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
||||
const destPath = path.join(imagesDir, fileName)
|
||||
|
||||
|
||||
if (!fs.existsSync(destPath)) {
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
relativePath: `media/images/${fileName}`,
|
||||
kind: 'image'
|
||||
@@ -566,6 +576,22 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转写语音为文字
|
||||
*/
|
||||
private async transcribeVoice(sessionId: string, msgId: string): Promise<string> {
|
||||
try {
|
||||
const transcript = await chatService.getVoiceTranscript(sessionId, msgId)
|
||||
if (transcript.success && transcript.transcript) {
|
||||
return `[语音转文字] ${transcript.transcript}`
|
||||
}
|
||||
return '[语音消息 - 转文字失败]'
|
||||
} catch (e) {
|
||||
console.error('[ExportService] 语音转文字失败:', e)
|
||||
return '[语音消息 - 转文字失败]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出表情文件
|
||||
*/
|
||||
@@ -579,7 +605,7 @@ class ExportService {
|
||||
// 使用消息对象中已提取的字段
|
||||
const emojiUrl = msg.emojiCdnUrl
|
||||
const emojiMd5 = msg.emojiMd5
|
||||
|
||||
|
||||
if (!emojiUrl && !emojiMd5) {
|
||||
console.log('[ExportService] 表情消息缺少 url 和 md5, localId:', msg.localId, 'content:', msg.content?.substring(0, 200))
|
||||
return null
|
||||
@@ -669,7 +695,7 @@ class ExportService {
|
||||
if (url.includes('%')) {
|
||||
url = decodeURIComponent(url)
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
return url
|
||||
}
|
||||
// 备用:尝试 XML 标签形式
|
||||
@@ -792,7 +818,7 @@ class ExportService {
|
||||
let imageDatName: string | undefined
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
|
||||
|
||||
if (localType === 3 && content) {
|
||||
// 图片消息
|
||||
imageMd5 = this.extractImageMd5(content)
|
||||
@@ -1057,6 +1083,31 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成通用的导出元数据 (参考 ChatLab 格式)
|
||||
*/
|
||||
private getExportMeta(
|
||||
sessionId: string,
|
||||
sessionInfo: { displayName: string },
|
||||
isGroup: boolean,
|
||||
sessionAvatar?: string
|
||||
): { chatlab: ChatLabHeader; meta: ChatLabMeta } {
|
||||
return {
|
||||
chatlab: {
|
||||
version: '0.0.2',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'WeFlow'
|
||||
},
|
||||
meta: {
|
||||
name: sessionInfo.displayName,
|
||||
platform: 'wechat',
|
||||
type: isGroup ? 'group' : 'private',
|
||||
...(isGroup && { groupId: sessionId }),
|
||||
...(sessionAvatar && { groupAvatar: sessionAvatar })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单个会话为 ChatLab 格式
|
||||
*/
|
||||
@@ -1097,21 +1148,29 @@ class ExportService {
|
||||
phase: 'exporting'
|
||||
})
|
||||
|
||||
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
|
||||
const chatLabMessages: ChatLabMessage[] = []
|
||||
for (const msg of allMessages) {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||
platformId: msg.senderUsername,
|
||||
accountName: msg.senderUsername,
|
||||
groupNickname: undefined
|
||||
}
|
||||
return {
|
||||
|
||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
||||
// 如果是语音消息且开启了转文字
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
content = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
}
|
||||
|
||||
chatLabMessages.push({
|
||||
sender: msg.senderUsername,
|
||||
accountName: memberInfo.accountName,
|
||||
groupNickname: memberInfo.groupNickname,
|
||||
timestamp: msg.createTime,
|
||||
type: this.convertMessageType(msg.localType, msg.content),
|
||||
content: this.parseMessageContent(msg.content, msg.localType)
|
||||
}
|
||||
})
|
||||
content: content
|
||||
})
|
||||
}
|
||||
|
||||
const avatarMap = options.exportAvatars
|
||||
? await this.exportAvatars(
|
||||
@@ -1131,19 +1190,11 @@ class ExportService {
|
||||
return avatar ? { ...info.member, avatar } : info.member
|
||||
})
|
||||
|
||||
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)
|
||||
|
||||
const chatLabExport: ChatLabExport = {
|
||||
chatlab: {
|
||||
version: '0.0.1',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'WeFlow'
|
||||
},
|
||||
meta: {
|
||||
name: sessionInfo.displayName,
|
||||
platform: 'wechat',
|
||||
type: isGroup ? 'group' : 'private',
|
||||
...(isGroup && { groupId: sessionId }),
|
||||
...(sessionAvatar && { groupAvatar: sessionAvatar })
|
||||
},
|
||||
chatlab,
|
||||
meta,
|
||||
members,
|
||||
messages: chatLabMessages
|
||||
}
|
||||
@@ -1245,7 +1296,11 @@ class ExportService {
|
||||
phase: 'writing'
|
||||
})
|
||||
|
||||
const detailedExport = {
|
||||
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||
|
||||
const detailedExport: any = {
|
||||
chatlab,
|
||||
meta,
|
||||
session: {
|
||||
wxid: sessionId,
|
||||
nickname: sessionInfo.displayName,
|
||||
@@ -1316,7 +1371,7 @@ class ExportService {
|
||||
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
|
||||
|
||||
// 获取会话的备注信息
|
||||
const sessionContact = await wcdbService.getContact(sessionId)
|
||||
const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
|
||||
@@ -1362,12 +1417,12 @@ class ExportService {
|
||||
worksheet.mergeCells(currentRow, 2, currentRow, 3)
|
||||
worksheet.getCell(currentRow, 2).value = sessionId
|
||||
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
|
||||
worksheet.getCell(currentRow, 4).value = '昵称'
|
||||
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(currentRow, 5).value = sessionNickname
|
||||
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
|
||||
if (isGroup) {
|
||||
worksheet.getCell(currentRow, 6).value = '备注'
|
||||
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
@@ -1378,11 +1433,36 @@ class ExportService {
|
||||
worksheet.getRow(currentRow).height = 20
|
||||
currentRow++
|
||||
|
||||
// 第三行:导出元数据
|
||||
const { chatlab, meta: exportMeta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||
worksheet.getCell(currentRow, 1).value = '导出工具'
|
||||
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(currentRow, 2).value = chatlab.generator
|
||||
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
worksheet.getCell(currentRow, 3).value = '导出版本'
|
||||
worksheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(currentRow, 4).value = chatlab.version
|
||||
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
worksheet.getCell(currentRow, 5).value = '平台'
|
||||
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(currentRow, 6).value = exportMeta.platform
|
||||
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
worksheet.getCell(currentRow, 7).value = '导出时间'
|
||||
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(currentRow, 8).value = this.formatTimestamp(chatlab.exportedAt)
|
||||
worksheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
worksheet.getRow(currentRow).height = 20
|
||||
currentRow++
|
||||
|
||||
// 表头行
|
||||
const headers = ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
|
||||
const headerRow = worksheet.getRow(currentRow)
|
||||
headerRow.height = 22
|
||||
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
const cell = headerRow.getCell(index + 1)
|
||||
cell.value = header
|
||||
@@ -1408,17 +1488,17 @@ class ExportService {
|
||||
|
||||
// 填充数据
|
||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||
|
||||
|
||||
// 媒体导出设置
|
||||
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis
|
||||
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
|
||||
|
||||
|
||||
// 媒体导出缓存
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
|
||||
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
const msg = sortedMessages[i]
|
||||
|
||||
|
||||
// 导出媒体文件
|
||||
let mediaItem: MediaExportItem | null = null
|
||||
if (exportMediaEnabled) {
|
||||
@@ -1429,18 +1509,19 @@ class ExportService {
|
||||
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 确定发送者信息
|
||||
let senderRole: string
|
||||
let senderWxid: string
|
||||
let senderNickname: string
|
||||
let senderRemark: string = ''
|
||||
|
||||
|
||||
if (msg.isSend) {
|
||||
// 我发送的消息
|
||||
senderRole = '我'
|
||||
@@ -1450,7 +1531,7 @@ class ExportService {
|
||||
} else if (isGroup && msg.senderUsername) {
|
||||
// 群消息
|
||||
senderWxid = msg.senderUsername
|
||||
|
||||
|
||||
// 用 getContact 获取联系人详情,分别取昵称和备注
|
||||
const contactDetail = await wcdbService.getContact(msg.senderUsername)
|
||||
if (contactDetail.success && contactDetail.contact) {
|
||||
@@ -1481,12 +1562,12 @@ class ExportService {
|
||||
|
||||
const row = worksheet.getRow(currentRow)
|
||||
row.height = 24
|
||||
|
||||
|
||||
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
|
||||
const contentValue = mediaItem
|
||||
? mediaItem.relativePath
|
||||
const contentValue = mediaItem
|
||||
? mediaItem.relativePath
|
||||
: (this.parseMessageContent(msg.content, msg.localType) || '')
|
||||
|
||||
|
||||
// 调试日志
|
||||
if (msg.localType === 3 || msg.localType === 47) {
|
||||
console.log('[ExportService] 媒体消息填充表格:', {
|
||||
@@ -1497,7 +1578,7 @@ class ExportService {
|
||||
contentValue: contentValue?.substring(0, 100)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
worksheet.getCell(currentRow, 1).value = i + 1
|
||||
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||
@@ -1506,14 +1587,14 @@ class ExportService {
|
||||
worksheet.getCell(currentRow, 6).value = senderRole
|
||||
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
|
||||
worksheet.getCell(currentRow, 8).value = contentValue
|
||||
|
||||
|
||||
// 设置每个单元格的样式
|
||||
for (let col = 1; col <= 8; col++) {
|
||||
const cell = worksheet.getCell(currentRow, col)
|
||||
cell.font = { name: 'Calibri', size: 11 }
|
||||
cell.alignment = { vertical: 'middle', wrapText: false }
|
||||
}
|
||||
|
||||
|
||||
currentRow++
|
||||
|
||||
// 每处理 100 条消息报告一次进度
|
||||
@@ -1548,14 +1629,14 @@ class ExportService {
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('ExportService: 导出 Excel 失败:', e)
|
||||
|
||||
|
||||
// 处理文件被占用的错误
|
||||
if (e instanceof Error) {
|
||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -1594,13 +1675,13 @@ class ExportService {
|
||||
})
|
||||
|
||||
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
|
||||
|
||||
|
||||
// 为每个会话创建单独的文件夹
|
||||
const sessionDir = path.join(outputDir, safeName)
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true })
|
||||
}
|
||||
|
||||
|
||||
let ext = '.json'
|
||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||
else if (options.format === 'excel') ext = '.xlsx'
|
||||
|
||||
@@ -14,17 +14,17 @@ function getStaticFfmpegPath(): string | null {
|
||||
// 方法1: 直接 require ffmpeg-static
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
|
||||
|
||||
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
||||
return ffmpegStatic
|
||||
}
|
||||
|
||||
|
||||
// 方法2: 手动构建路径(开发环境)
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
|
||||
// 方法3: 打包后的路径
|
||||
if (app.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
@@ -33,7 +33,7 @@ function getStaticFfmpegPath(): string | null {
|
||||
return packedPath
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
@@ -115,7 +115,6 @@ export class ImageDecryptService {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
@@ -135,7 +134,6 @@ export class ImageDecryptService {
|
||||
for (const key of cacheKeys) {
|
||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
|
||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
@@ -277,12 +275,12 @@ export class ImageDecryptService {
|
||||
decrypted = wxgfResult.data
|
||||
|
||||
let ext = this.detectImageExtension(decrypted)
|
||||
|
||||
|
||||
// 如果是 wxgf 格式且没检测到扩展名
|
||||
if (wxgfResult.isWxgf && !ext) {
|
||||
ext = '.hevc'
|
||||
}
|
||||
|
||||
|
||||
const finalExt = ext || '.jpg'
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
@@ -291,8 +289,8 @@ export class ImageDecryptService {
|
||||
|
||||
// 对于 hevc 格式,返回错误提示
|
||||
if (finalExt === '.hevc') {
|
||||
return {
|
||||
success: false,
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
@@ -1475,29 +1473,29 @@ export class ImageDecryptService {
|
||||
*/
|
||||
private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> {
|
||||
// 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf")
|
||||
if (buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
|
||||
if (buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
|
||||
return { data: buffer, isWxgf: false }
|
||||
}
|
||||
|
||||
|
||||
// 先尝试搜索内嵌的传统图片签名
|
||||
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) {
|
||||
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
|
||||
return { data: buffer.subarray(i), isWxgf: false }
|
||||
}
|
||||
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
|
||||
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
|
||||
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
|
||||
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
|
||||
return { data: buffer.subarray(i), isWxgf: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 提取 HEVC NALU 裸流
|
||||
const hevcData = this.extractHevcNalu(buffer)
|
||||
if (!hevcData || hevcData.length < 100) {
|
||||
return { data: buffer, isWxgf: true }
|
||||
}
|
||||
|
||||
|
||||
// 尝试用 ffmpeg 转换
|
||||
try {
|
||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
||||
@@ -1507,7 +1505,7 @@ export class ImageDecryptService {
|
||||
} catch {
|
||||
// ffmpeg 转换失败
|
||||
}
|
||||
|
||||
|
||||
return { data: hevcData, isWxgf: true }
|
||||
}
|
||||
|
||||
@@ -1517,23 +1515,23 @@ export class ImageDecryptService {
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
let i = 4
|
||||
|
||||
|
||||
while (i < buffer.length - 4) {
|
||||
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||
let nalStart = i
|
||||
let nalEnd = buffer.length
|
||||
|
||||
|
||||
for (let j = i + 4; j < buffer.length - 3; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
|
||||
if (buffer[j + 2] === 0x01 ||
|
||||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||
if (buffer[j + 2] === 0x01 ||
|
||||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||
nalEnd = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const nalUnit = buffer.subarray(nalStart, nalEnd)
|
||||
if (nalUnit.length > 3) {
|
||||
nalUnits.push(nalUnit)
|
||||
@@ -1543,17 +1541,17 @@ export class ImageDecryptService {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (nalUnits.length === 0) {
|
||||
for (let j = 4; j < buffer.length - 4; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||
return buffer.subarray(j)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
return Buffer.concat(nalUnits)
|
||||
}
|
||||
|
||||
@@ -1563,11 +1561,11 @@ export class ImageDecryptService {
|
||||
private getFfmpegPath(): string {
|
||||
const staticPath = getStaticFfmpegPath()
|
||||
this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false })
|
||||
|
||||
|
||||
if (staticPath) {
|
||||
return staticPath
|
||||
}
|
||||
|
||||
|
||||
// 回退到系统 ffmpeg
|
||||
return 'ffmpeg'
|
||||
}
|
||||
@@ -1578,12 +1576,12 @@ export class ImageDecryptService {
|
||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||
const ffmpeg = this.getFfmpegPath()
|
||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process')
|
||||
const chunks: Buffer[] = []
|
||||
const errChunks: Buffer[] = []
|
||||
|
||||
|
||||
const proc = spawn(ffmpeg, [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
@@ -1593,14 +1591,14 @@ export class ImageDecryptService {
|
||||
'-q:v', '3',
|
||||
'-f', 'mjpeg',
|
||||
'pipe:1'
|
||||
], {
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0 && chunks.length > 0) {
|
||||
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
||||
@@ -1611,12 +1609,12 @@ export class ImageDecryptService {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
proc.on('error', (err: Error) => {
|
||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
|
||||
proc.stdin.write(hevcData)
|
||||
proc.stdin.end()
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ export class MessageCacheService {
|
||||
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, 'session-messages.json')
|
||||
this.ensureCacheDir()
|
||||
this.loadCache()
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { app } from 'electron'
|
||||
import { createWriteStream, existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { execFile, spawnSync } from 'child_process'
|
||||
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
|
||||
import { join } from 'path'
|
||||
import * as https from 'https'
|
||||
import * as http from 'http'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
// Sherpa-onnx 类型定义
|
||||
type OfflineRecognizer = any
|
||||
type OfflineStream = any
|
||||
|
||||
type WhisperModelInfo = {
|
||||
type ModelInfo = {
|
||||
name: string
|
||||
fileName: string
|
||||
files: {
|
||||
model: string
|
||||
tokens: string
|
||||
vad: string
|
||||
}
|
||||
sizeBytes: number
|
||||
sizeLabel: string
|
||||
sizeBytes?: number
|
||||
}
|
||||
|
||||
type DownloadProgress = {
|
||||
@@ -23,122 +27,169 @@ type DownloadProgress = {
|
||||
percent?: number
|
||||
}
|
||||
|
||||
const WHISPER_MODELS: Record<string, WhisperModelInfo> = {
|
||||
tiny: { name: 'tiny', fileName: 'ggml-tiny.bin', sizeLabel: '75 MB', sizeBytes: 75_000_000 },
|
||||
base: { name: 'base', fileName: 'ggml-base.bin', sizeLabel: '142 MB', sizeBytes: 142_000_000 },
|
||||
small: { name: 'small', fileName: 'ggml-small.bin', sizeLabel: '466 MB', sizeBytes: 466_000_000 },
|
||||
medium: { name: 'medium', fileName: 'ggml-medium.bin', sizeLabel: '1.5 GB', sizeBytes: 1_500_000_000 },
|
||||
'large-v3': { name: 'large-v3', fileName: 'ggml-large-v3.bin', sizeLabel: '2.9 GB', sizeBytes: 2_900_000_000 }
|
||||
const SENSEVOICE_MODEL: ModelInfo = {
|
||||
name: 'SenseVoiceSmall',
|
||||
files: {
|
||||
model: 'model.int8.onnx',
|
||||
tokens: 'tokens.txt',
|
||||
vad: 'silero_vad.onnx'
|
||||
},
|
||||
sizeBytes: 245_000_000,
|
||||
sizeLabel: '245 MB'
|
||||
}
|
||||
|
||||
const WHISPER_SOURCES: Record<string, string> = {
|
||||
official: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main',
|
||||
tsinghua: 'https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main'
|
||||
}
|
||||
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
||||
return ffmpegStatic
|
||||
}
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
if (app.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(packedPath)) {
|
||||
return packedPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const MODEL_DOWNLOAD_URLS = {
|
||||
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
|
||||
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt',
|
||||
vad: 'https://www.modelscope.cn/models/manyeyes/silero-vad-onnx/resolve/master/silero_vad.onnx'
|
||||
}
|
||||
|
||||
export class VoiceTranscribeService {
|
||||
private configService = new ConfigService()
|
||||
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
|
||||
private recognizer: OfflineRecognizer | null = null
|
||||
private isInitializing = false
|
||||
|
||||
private resolveModelInfo(modelName: string): WhisperModelInfo | null {
|
||||
return WHISPER_MODELS[modelName] || null
|
||||
}
|
||||
|
||||
private resolveModelDir(overrideDir?: string): string {
|
||||
const configured = overrideDir || this.configService.get('whisperModelDir')
|
||||
private resolveModelDir(): string {
|
||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||
if (configured) return configured
|
||||
return join(app.getPath('userData'), 'models', 'whisper')
|
||||
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
|
||||
}
|
||||
|
||||
private resolveModelPath(modelName: string, overrideDir?: string): string | null {
|
||||
const info = this.resolveModelInfo(modelName)
|
||||
if (!info) return null
|
||||
return join(this.resolveModelDir(overrideDir), info.fileName)
|
||||
private resolveModelPath(fileName: string): string {
|
||||
return join(this.resolveModelDir(), fileName)
|
||||
}
|
||||
|
||||
private resolveSourceUrl(overrideSource?: string): string {
|
||||
const configured = overrideSource || this.configService.get('whisperDownloadSource')
|
||||
if (configured && WHISPER_SOURCES[configured]) return WHISPER_SOURCES[configured]
|
||||
return WHISPER_SOURCES.official
|
||||
}
|
||||
|
||||
async getModelStatus(payload: { modelName: string; downloadDir?: string }): Promise<{
|
||||
/**
|
||||
* 检查模型状态
|
||||
*/
|
||||
async getModelStatus(): Promise<{
|
||||
success: boolean
|
||||
exists?: boolean
|
||||
path?: string
|
||||
modelPath?: string
|
||||
tokensPath?: string
|
||||
sizeBytes?: number
|
||||
error?: string
|
||||
}> {
|
||||
const modelPath = this.resolveModelPath(payload.modelName, payload.downloadDir)
|
||||
if (!modelPath) {
|
||||
return { success: false, error: '未知模型名称' }
|
||||
try {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
const vadPath = this.resolveModelPath((SENSEVOICE_MODEL.files as any).vad)
|
||||
|
||||
const modelExists = existsSync(modelPath)
|
||||
const tokensExists = existsSync(tokensPath)
|
||||
const vadExists = existsSync(vadPath)
|
||||
const exists = modelExists && tokensExists && vadExists
|
||||
|
||||
if (!exists) {
|
||||
return { success: true, exists: false, modelPath, tokensPath }
|
||||
}
|
||||
|
||||
const modelSize = statSync(modelPath).size
|
||||
const tokensSize = statSync(tokensPath).size
|
||||
const vadSize = statSync(vadPath).size
|
||||
const totalSize = modelSize + tokensSize + vadSize
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exists: true,
|
||||
modelPath,
|
||||
tokensPath,
|
||||
sizeBytes: totalSize
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VoiceTranscribe] getModelStatus error:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
if (!existsSync(modelPath)) {
|
||||
return { success: true, exists: false, path: modelPath }
|
||||
}
|
||||
const sizeBytes = statSync(modelPath).size
|
||||
return { success: true, exists: true, path: modelPath, sizeBytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载模型文件
|
||||
*/
|
||||
async downloadModel(
|
||||
payload: { modelName: string; downloadDir?: string; source?: string },
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<{ success: boolean; path?: string; error?: string }> {
|
||||
const info = this.resolveModelInfo(payload.modelName)
|
||||
if (!info) {
|
||||
return { success: false, error: '未知模型名称' }
|
||||
}
|
||||
|
||||
const modelPath = this.resolveModelPath(payload.modelName, payload.downloadDir)
|
||||
if (!modelPath) {
|
||||
return { success: false, error: '模型路径生成失败' }
|
||||
}
|
||||
|
||||
if (existsSync(modelPath)) {
|
||||
return { success: true, path: modelPath }
|
||||
}
|
||||
|
||||
const cacheKey = `${payload.modelName}:${modelPath}`
|
||||
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
|
||||
const cacheKey = 'sensevoice'
|
||||
const pending = this.downloadTasks.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const targetDir = this.resolveModelDir(payload.downloadDir)
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
const modelDir = this.resolveModelDir()
|
||||
if (!existsSync(modelDir)) {
|
||||
mkdirSync(modelDir, { recursive: true })
|
||||
}
|
||||
|
||||
const baseUrl = this.resolveSourceUrl(payload.source)
|
||||
const url = `${baseUrl}/${info.fileName}`
|
||||
await this.downloadToFile(url, modelPath, payload.modelName, onProgress)
|
||||
return { success: true, path: modelPath }
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
const vadPath = this.resolveModelPath((SENSEVOICE_MODEL.files as any).vad)
|
||||
|
||||
// 下载模型文件 (40%)
|
||||
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.model,
|
||||
modelPath,
|
||||
'model',
|
||||
(downloaded, total) => {
|
||||
const percent = total ? (downloaded / total) * 40 : undefined
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 下载 tokens 文件 (30%)
|
||||
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.tokens,
|
||||
tokensPath,
|
||||
'tokens',
|
||||
(downloaded, total) => {
|
||||
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||
const percent = total ? 40 + (downloaded / total) * 30 : 40
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: modelSize + downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 下载 vad 文件 (30%)
|
||||
console.info('[VoiceTranscribe] 开始下载 VAD 文件...')
|
||||
await this.downloadToFile(
|
||||
(MODEL_DOWNLOAD_URLS as any).vad,
|
||||
vadPath,
|
||||
'vad',
|
||||
(downloaded, total) => {
|
||||
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||
const tokensSize = existsSync(tokensPath) ? statSync(tokensPath).size : 0
|
||||
const percent = total ? 70 + (downloaded / total) * 30 : 70
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: modelSize + tokensSize + downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
console.info('[VoiceTranscribe] 模型下载完成')
|
||||
return { success: true, modelPath, tokensPath }
|
||||
} catch (error) {
|
||||
try { if (existsSync(modelPath)) unlinkSync(modelPath) } catch { }
|
||||
console.error('[VoiceTranscribe] 下载失败:', error)
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
const vadPath = this.resolveModelPath((SENSEVOICE_MODEL.files as any).vad)
|
||||
try {
|
||||
if (existsSync(modelPath)) unlinkSync(modelPath)
|
||||
if (existsSync(tokensPath)) unlinkSync(tokensPath)
|
||||
if (existsSync(vadPath)) unlinkSync(vadPath)
|
||||
} catch { }
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
this.downloadTasks.delete(cacheKey)
|
||||
@@ -149,102 +200,108 @@ export class VoiceTranscribeService {
|
||||
return task
|
||||
}
|
||||
|
||||
async transcribeWavBuffer(wavData: Buffer): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
const modelName = this.configService.get('whisperModelName') || 'base'
|
||||
const modelPath = this.resolveModelPath(modelName)
|
||||
console.info('[VoiceTranscribe] check model', { modelName, modelPath, exists: modelPath ? existsSync(modelPath) : false })
|
||||
if (!modelPath || !existsSync(modelPath)) {
|
||||
return { success: false, error: '未下载语音模型,请在设置中下载' }
|
||||
}
|
||||
/**
|
||||
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
||||
*/
|
||||
async transcribeWavBuffer(
|
||||
wavData: Buffer,
|
||||
onPartial?: (text: string) => void
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
|
||||
// 使用内置的预编译 whisper-cli.exe
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const whisperExe = join(resourcesPath, 'whisper-cli.exe')
|
||||
|
||||
if (!existsSync(whisperExe)) {
|
||||
return { success: false, error: '找不到语音转写程序,请重新安装应用' }
|
||||
}
|
||||
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
|
||||
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
|
||||
return
|
||||
}
|
||||
|
||||
const ffmpegPath = getStaticFfmpegPath() || 'ffmpeg'
|
||||
console.info('[VoiceTranscribe] ffmpeg path', ffmpegPath)
|
||||
const { Worker } = require('worker_threads')
|
||||
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||
|
||||
const tempDir = app.getPath('temp')
|
||||
const fileToken = `${Date.now()}_${Math.random().toString(16).slice(2)}`
|
||||
const inputPath = join(tempDir, `weflow_voice_${fileToken}.wav`)
|
||||
const outputPath = join(tempDir, `weflow_voice_${fileToken}_16k.wav`)
|
||||
console.info('[VoiceTranscribe] 启动后台 Worker 转写...', { workerPath })
|
||||
|
||||
try {
|
||||
writeFileSync(inputPath, wavData)
|
||||
console.info('[VoiceTranscribe] converting to 16kHz', { inputPath, outputPath })
|
||||
await execFileAsync(ffmpegPath, ['-y', '-i', inputPath, '-ar', '16000', '-ac', '1', outputPath])
|
||||
|
||||
console.info('[VoiceTranscribe] transcribing with whisper', { whisperExe, modelPath })
|
||||
const { stdout, stderr } = await execFileAsync(whisperExe, [
|
||||
'-m', modelPath,
|
||||
'-f', outputPath,
|
||||
'-l', 'zh',
|
||||
'-otxt',
|
||||
'-np' // no prints (只输出结果)
|
||||
], {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
cwd: dirname(whisperExe), // 设置工作目录为 whisper-cli.exe 所在目录,确保能找到 DLL
|
||||
env: { ...process.env, PATH: `${dirname(whisperExe)};${process.env.PATH}` }
|
||||
})
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
modelPath,
|
||||
tokensPath,
|
||||
wavData,
|
||||
sampleRate: 16000
|
||||
}
|
||||
})
|
||||
|
||||
console.info('[VoiceTranscribe] whisper stdout:', stdout)
|
||||
if (stderr) console.warn('[VoiceTranscribe] whisper stderr:', stderr)
|
||||
let finalTranscript = ''
|
||||
|
||||
// 解析输出文本
|
||||
const outputBase = outputPath.replace(/\.[^.]+$/, '')
|
||||
const txtFile = `${outputBase}.txt`
|
||||
let transcript = ''
|
||||
if (existsSync(txtFile)) {
|
||||
const { readFileSync } = await import('fs')
|
||||
transcript = readFileSync(txtFile, 'utf-8').trim()
|
||||
unlinkSync(txtFile)
|
||||
} else {
|
||||
// 从 stdout 提取(使用 -np 参数后,stdout 只有转写结果)
|
||||
transcript = stdout.trim()
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'partial') {
|
||||
onPartial?.(msg.text)
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
} else if (msg.type === 'error') {
|
||||
resolve({ success: false, error: msg.error })
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err: Error) => {
|
||||
console.error('[VoiceTranscribe] Worker error:', err)
|
||||
resolve({ success: false, error: String(err) })
|
||||
})
|
||||
|
||||
worker.on('exit', (code: number) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
||||
resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[VoiceTranscribe] 启动 Worker 失败:', error)
|
||||
resolve({ success: false, error: String(error) })
|
||||
}
|
||||
|
||||
console.info('[VoiceTranscribe] success', { transcript })
|
||||
return { success: true, transcript }
|
||||
} catch (error: any) {
|
||||
console.error('[VoiceTranscribe] failed', error)
|
||||
console.error('[VoiceTranscribe] stderr:', error.stderr)
|
||||
console.error('[VoiceTranscribe] stdout:', error.stdout)
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
try { if (existsSync(inputPath)) unlinkSync(inputPath) } catch { }
|
||||
try { if (existsSync(outputPath)) unlinkSync(outputPath) } catch { }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
private downloadToFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
modelName: string,
|
||||
onProgress?: (progress: DownloadProgress) => void,
|
||||
remainingRedirects = 3
|
||||
fileName: string,
|
||||
onProgress?: (downloaded: number, total?: number) => void,
|
||||
remainingRedirects = 5
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const request = protocol.get(url, (response) => {
|
||||
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
}
|
||||
|
||||
const request = protocol.get(url, options, (response) => {
|
||||
// 处理重定向
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
||||
if (remainingRedirects <= 0) {
|
||||
reject(new Error('下载重定向次数过多'))
|
||||
reject(new Error('重定向次数过多'))
|
||||
return
|
||||
}
|
||||
this.downloadToFile(response.headers.location, targetPath, modelName, onProgress, remainingRedirects - 1)
|
||||
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
||||
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`下载失败: ${response.statusCode}`))
|
||||
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,8 +312,7 @@ export class VoiceTranscribeService {
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length
|
||||
const percent = totalBytes ? (downloadedBytes / totalBytes) * 100 : undefined
|
||||
onProgress?.({ modelName, downloadedBytes, totalBytes, percent })
|
||||
onProgress?.(downloadedBytes, totalBytes)
|
||||
})
|
||||
|
||||
response.on('error', (error) => {
|
||||
@@ -271,15 +327,33 @@ export class VoiceTranscribeService {
|
||||
|
||||
writer.on('finish', () => {
|
||||
writer.close()
|
||||
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.pipe(writer)
|
||||
})
|
||||
|
||||
request.on('error', reject)
|
||||
request.on('error', (error) => {
|
||||
console.error(`[VoiceTranscribe] ${fileName} 下载错误:`, error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
dispose() {
|
||||
if (this.recognizer) {
|
||||
try {
|
||||
// sherpa-onnx 的 recognizer 可能需要手动释放
|
||||
this.recognizer = null
|
||||
} catch (error) {
|
||||
console.error('[VoiceTranscribe] 释放识别器失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||
|
||||
@@ -48,6 +48,7 @@ export class WcdbCore {
|
||||
private wcdbGetMessageById: any = null
|
||||
private wcdbGetEmoticonCdnUrl: any = null
|
||||
private wcdbGetDbStatus: any = null
|
||||
private wcdbGetVoiceData: any = null
|
||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
private logTimer: NodeJS.Timeout | null = null
|
||||
@@ -108,12 +109,13 @@ export class WcdbCore {
|
||||
|
||||
private writeLog(message: string, force = false): void {
|
||||
if (!force && !this.isLogEnabled()) return
|
||||
const line = `[${new Date().toISOString()}] ${message}`
|
||||
console.log(`[WCDB] ${line}`)
|
||||
try {
|
||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||
const dir = join(base, 'logs')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
const line = `[${new Date().toISOString()}] ${message}\n`
|
||||
appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' })
|
||||
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
|
||||
} catch { }
|
||||
}
|
||||
|
||||
@@ -345,6 +347,13 @@ export class WcdbCore {
|
||||
this.wcdbGetDbStatus = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_voice_data(wcdb_handle handle, const char* session_id, int32_t create_time, const char* candidates_json, char** out_hex)
|
||||
try {
|
||||
this.wcdbGetVoiceData = this.lib.func('int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int64 svrId, const char* candidatesJson, _Out_ void** outHex)')
|
||||
} catch {
|
||||
this.wcdbGetVoiceData = null
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const initResult = this.wcdbInit()
|
||||
if (initResult !== 0) {
|
||||
@@ -1295,9 +1304,7 @@ export class WcdbCore {
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
||||
} async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
@@ -1313,5 +1320,21 @@ export class WcdbCore {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getVoiceData(sessionId: string, createTime: number, candidates: string[], svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取语音数据失败: ${result}` }
|
||||
}
|
||||
const hex = this.decodeJsonPtr(outPtr[0])
|
||||
if (hex === null) return { success: false, error: '解析语音数据失败' }
|
||||
return { success: true, hex: hex || undefined }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,13 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageById', { sessionId, localId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音数据
|
||||
*/
|
||||
async getVoiceData(sessionId: string, createTime: number, candidates: string[], svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, svrId })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const wcdbService = new WcdbService()
|
||||
|
||||
174
electron/transcribeWorker.ts
Normal file
174
electron/transcribeWorker.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import * as fs from 'fs'
|
||||
|
||||
interface WorkerParams {
|
||||
modelPath: string
|
||||
tokensPath: string
|
||||
wavData: Buffer
|
||||
sampleRate: number
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.info('[TranscribeWorker] Worker process starting...');
|
||||
|
||||
if (!parentPort) {
|
||||
console.error('[TranscribeWorker] Critical Error: parentPort is null');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.info('[TranscribeWorker] Loading sherpa-onnx-node...');
|
||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||
let sherpa: any;
|
||||
try {
|
||||
sherpa = require('sherpa-onnx-node');
|
||||
console.info('[TranscribeWorker] sherpa-onnx-node loaded successfully.');
|
||||
} catch (requireError) {
|
||||
console.error('[TranscribeWorker] Failed to load sherpa-onnx-node:', requireError);
|
||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate } = workerData as WorkerParams
|
||||
const wavData = Buffer.from(rawWavData);
|
||||
console.info('[TranscribeWorker] Params received:', {
|
||||
modelPath,
|
||||
tokensPath,
|
||||
sampleRate,
|
||||
wavDataLength: wavData?.length
|
||||
});
|
||||
|
||||
// 1. 初始化识别器 (SenseVoiceSmall)
|
||||
console.info('[TranscribeWorker] Initializing OfflineRecognizer...');
|
||||
const recognizerConfig = {
|
||||
modelConfig: {
|
||||
senseVoice: {
|
||||
model: modelPath,
|
||||
useInverseTextNormalization: 1
|
||||
},
|
||||
tokens: tokensPath,
|
||||
numThreads: 2,
|
||||
debug: 0
|
||||
}
|
||||
}
|
||||
const recognizer = new sherpa.OfflineRecognizer(recognizerConfig)
|
||||
console.info('[TranscribeWorker] OfflineRecognizer initialized.');
|
||||
|
||||
// 2. 初始化 VAD (用于流式输出效果)
|
||||
const vadPath = modelPath.replace('model.int8.onnx', 'silero_vad.onnx');
|
||||
console.info('[TranscribeWorker] VAD Path:', vadPath);
|
||||
|
||||
const vadConfig = {
|
||||
sileroVad: {
|
||||
model: vadPath,
|
||||
threshold: 0.5,
|
||||
minSilenceDuration: 0.5,
|
||||
minSpeechDuration: 0.25,
|
||||
windowSize: 512
|
||||
},
|
||||
sampleRate: sampleRate,
|
||||
debug: 0,
|
||||
numThreads: 1
|
||||
}
|
||||
|
||||
// 检查 VAD 模型是否存在,如果不存在则退回到全量识别
|
||||
if (!fs.existsSync(vadPath)) {
|
||||
console.warn('[TranscribeWorker] VAD model not found, falling back to full transcription.');
|
||||
|
||||
const pcmData = wavData.slice(44)
|
||||
const samples = new Float32Array(pcmData.length / 2)
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
|
||||
}
|
||||
|
||||
const stream = recognizer.createStream()
|
||||
stream.acceptWaveform({ sampleRate, samples })
|
||||
recognizer.decode(stream)
|
||||
const result = recognizer.getResult(stream)
|
||||
|
||||
console.info('[TranscribeWorker] Full transcription result:', result.text);
|
||||
parentPort.postMessage({ type: 'final', text: result.text })
|
||||
return
|
||||
}
|
||||
|
||||
console.info('[TranscribeWorker] Initializing Vad...');
|
||||
const vad = new sherpa.Vad(vadConfig, 60) // 60s max
|
||||
console.info('[TranscribeWorker] VAD initialized.');
|
||||
|
||||
// 3. 处理音频数据
|
||||
const pcmData = wavData.slice(44)
|
||||
const samples = new Float32Array(pcmData.length / 2)
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
|
||||
}
|
||||
|
||||
// 模拟流式输入:按小块喂给 VAD
|
||||
const chunkSize = 1600 // 100ms for 16kHz
|
||||
let offset = 0
|
||||
let accumulatedText = ''
|
||||
|
||||
console.info('[TranscribeWorker] Starting processing loop...');
|
||||
let segmentCount = 0;
|
||||
|
||||
while (offset < samples.length) {
|
||||
const end = Math.min(offset + chunkSize, samples.length)
|
||||
const chunk = samples.subarray(offset, end)
|
||||
|
||||
vad.acceptWaveform(chunk)
|
||||
|
||||
// 检查 ASR 结果
|
||||
while (!vad.isEmpty()) {
|
||||
const segment = vad.front(false)
|
||||
|
||||
// Log segment detection
|
||||
console.info(`[TranscribeWorker] VAD Segment detected. Duration: ${segment.samples.length / sampleRate}s`);
|
||||
|
||||
const stream = recognizer.createStream()
|
||||
stream.acceptWaveform({ sampleRate, samples: segment.samples })
|
||||
recognizer.decode(stream)
|
||||
const result = recognizer.getResult(stream)
|
||||
|
||||
if (result.text) {
|
||||
const text = result.text.trim();
|
||||
if (text.length > 0) {
|
||||
accumulatedText += (accumulatedText ? ' ' : '') + text
|
||||
segmentCount++;
|
||||
console.info(`[TranscribeWorker] Partial update #${segmentCount}: "${text}" -> Total: "${accumulatedText.substring(0, 50)}..."`);
|
||||
parentPort.postMessage({ type: 'partial', text: accumulatedText })
|
||||
}
|
||||
}
|
||||
vad.pop()
|
||||
}
|
||||
|
||||
offset = end
|
||||
// 让出主循环,保持响应
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
// Ensure any remaining buffer is processed
|
||||
vad.flush();
|
||||
while (!vad.isEmpty()) {
|
||||
const segment = vad.front(false);
|
||||
console.info(`[TranscribeWorker] Final VAD Segment detected. Duration: ${segment.samples.length / sampleRate}s`);
|
||||
const stream = recognizer.createStream()
|
||||
stream.acceptWaveform({ sampleRate, samples: segment.samples })
|
||||
recognizer.decode(stream)
|
||||
const result = recognizer.getResult(stream)
|
||||
if (result.text) {
|
||||
accumulatedText += (accumulatedText ? ' ' : '') + result.text.trim()
|
||||
console.info(`[TranscribeWorker] Final partial update: "${result.text.trim()}"`);
|
||||
parentPort.postMessage({ type: 'partial', text: accumulatedText })
|
||||
}
|
||||
vad.pop();
|
||||
}
|
||||
|
||||
console.info('[TranscribeWorker] Loop finished. Final text length:', accumulatedText.length);
|
||||
parentPort.postMessage({ type: 'final', text: accumulatedText })
|
||||
|
||||
} catch (error) {
|
||||
console.error('[TranscribeWorker] Fatal error:', error);
|
||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'sherpa-onnx-node' {
|
||||
const content: any;
|
||||
export = content;
|
||||
}
|
||||
@@ -110,6 +110,12 @@ if (parentPort) {
|
||||
case 'getMessageById':
|
||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||
break
|
||||
case 'getVoiceData':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.svrId)
|
||||
if (!result.success) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user