mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(voice-transcribe): 优化语音转写流程并增强数据库缓存机制
- 添加 createTime 参数到语音转写接口,支持更精确的消息定位 - 实现 media.db 列表缓存机制(5分钟TTL),减少重复查询开销 - 添加 media.db 表结构信息缓存,提升数据库操作效率 - 优化语音缓存目录获取逻辑,支持自定义缓存路径配置 - 重构语音数据获取实现,绕过WCDB的buggy getVoiceData方法 - 移除冗余的调试日志,提升代码整洁度 - 删除不再使用的 silk_v3_decoder.exe 文件 - 优化数据库连接流程,后台预热缓存提升响应速度
This commit is contained in:
@@ -83,7 +83,18 @@ class ChatService {
|
||||
private voiceWavCache = new Map<string, Buffer>()
|
||||
private voiceTranscriptCache = new Map<string, string>()
|
||||
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
||||
private mediaDbsCache: string[] | null = null
|
||||
private mediaDbsCacheTime = 0
|
||||
private readonly mediaDbsCacheTtl = 300000 // 5分钟
|
||||
private readonly voiceCacheMaxEntries = 50
|
||||
// 缓存 media.db 的表结构信息
|
||||
private mediaDbSchemaCache = new Map<string, {
|
||||
voiceTable: string
|
||||
dataColumn: string
|
||||
chatNameIdColumn?: string
|
||||
timeColumn?: string
|
||||
name2IdTable?: string
|
||||
}>()
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
@@ -140,6 +151,10 @@ class ChatService {
|
||||
}
|
||||
|
||||
this.connected = true
|
||||
|
||||
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
|
||||
this.warmupMediaDbsCache()
|
||||
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 连接数据库失败:', e)
|
||||
@@ -147,6 +162,21 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热 media 数据库列表缓存(后台异步执行)
|
||||
*/
|
||||
private async warmupMediaDbsCache(): Promise<void> {
|
||||
try {
|
||||
const result = await wcdbService.listMediaDbs()
|
||||
if (result.success && result.data) {
|
||||
this.mediaDbsCache = result.data as string[]
|
||||
this.mediaDbsCacheTime = Date.now()
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.connected && wcdbService.isReady()) {
|
||||
return { success: true }
|
||||
@@ -382,8 +412,6 @@ class ChatService {
|
||||
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
|
||||
|
||||
if (needNewCursor) {
|
||||
console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`)
|
||||
|
||||
// 关闭旧游标
|
||||
if (state) {
|
||||
try {
|
||||
@@ -440,7 +468,6 @@ class ChatService {
|
||||
}
|
||||
|
||||
// 获取当前批次的消息
|
||||
console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`)
|
||||
const batch = await wcdbService.fetchMessageBatch(state.cursor)
|
||||
if (!batch.success) {
|
||||
console.error('[ChatService] 获取消息批次失败:', batch.error)
|
||||
@@ -1691,21 +1718,17 @@ class ChatService {
|
||||
// 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中
|
||||
const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self']))
|
||||
|
||||
console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`)
|
||||
const result = await wcdbService.getAvatarUrls(fetchList)
|
||||
|
||||
if (result.success && result.map) {
|
||||
// 按优先级尝试匹配
|
||||
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
|
||||
if (avatarUrl) {
|
||||
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
|
||||
return { success: true, avatarUrl }
|
||||
}
|
||||
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
|
||||
return { success: true, avatarUrl: undefined }
|
||||
}
|
||||
|
||||
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
|
||||
return { success: true, avatarUrl: undefined }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取当前用户头像失败:', e)
|
||||
@@ -1716,6 +1739,19 @@ class ChatService {
|
||||
/**
|
||||
* 获取表情包缓存目录
|
||||
*/
|
||||
/**
|
||||
* 获取语音缓存目录
|
||||
*/
|
||||
private getVoiceCacheDir(): string {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (cachePath) {
|
||||
return join(cachePath, 'Voices')
|
||||
}
|
||||
// 回退到默认目录
|
||||
const documentsPath = app.getPath('documents')
|
||||
return join(documentsPath, 'WeFlow', 'Voices')
|
||||
}
|
||||
|
||||
private getEmojiCacheDir(): string {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (cachePath) {
|
||||
@@ -2085,12 +2121,6 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息' }
|
||||
}
|
||||
const msg = msgResult.message
|
||||
console.info('[ChatService][Image] request', {
|
||||
sessionId,
|
||||
localId: msg.localId,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName
|
||||
})
|
||||
|
||||
// 2. 确定搜索的基础名
|
||||
const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId)
|
||||
@@ -2107,7 +2137,6 @@ class ChatService {
|
||||
|
||||
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
|
||||
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
|
||||
console.info('[ChatService][Image] dat path', datPath)
|
||||
|
||||
// 4. 获取解密密钥
|
||||
const xorKeyRaw = this.configService.get('imageXorKey')
|
||||
@@ -2135,7 +2164,6 @@ class ChatService {
|
||||
const aesKey = this.asciiKey16(trimmed)
|
||||
decrypted = this.decryptDatV4(data, xorKey, aesKey)
|
||||
}
|
||||
console.info('[ChatService][Image] decrypted bytes', decrypted.length)
|
||||
|
||||
// 返回 base64
|
||||
return { success: true, data: decrypted.toString('base64') }
|
||||
@@ -2146,44 +2174,30 @@ class ChatService {
|
||||
}
|
||||
|
||||
/**
|
||||
* getVoiceData (优化的 C++ 实现 + 文件缓存)
|
||||
* getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取)
|
||||
*/
|
||||
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
|
||||
const startTime = Date.now()
|
||||
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
|
||||
let senderWxid: string | null = null
|
||||
|
||||
// 如果提供了传来的参数,验证其有效性
|
||||
if (!msgCreateTime || msgCreateTime === 0) {
|
||||
// 如果前端没传 createTime,才需要查询消息(这个很慢)
|
||||
if (!msgCreateTime) {
|
||||
const t1 = Date.now()
|
||||
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||
const t2 = Date.now()
|
||||
console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`)
|
||||
|
||||
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
|
||||
}
|
||||
msgCreateTime = msg.createTime
|
||||
senderWxid = msg.senderUsername || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2191,54 +2205,84 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息时间戳' }
|
||||
}
|
||||
|
||||
// 2. 构建查找候选 (sessionId, myWxid)
|
||||
// 使用 sessionId + createTime 作为缓存key
|
||||
const cacheKey = `${sessionId}_${msgCreateTime}`
|
||||
|
||||
// 检查 WAV 内存缓存
|
||||
const wavCache = this.voiceWavCache.get(cacheKey)
|
||||
if (wavCache) {
|
||||
console.log(`[Voice] 内存缓存命中,总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, data: wavCache.toString('base64') }
|
||||
}
|
||||
|
||||
// 检查 WAV 文件缓存
|
||||
const voiceCacheDir = this.getVoiceCacheDir()
|
||||
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
|
||||
if (existsSync(wavFilePath)) {
|
||||
try {
|
||||
const wavData = readFileSync(wavFilePath)
|
||||
// 同时缓存到内存
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
console.log(`[Voice] 文件缓存命中,总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
console.error('[Voice] 读取缓存文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建查找候选
|
||||
const candidates: string[] = []
|
||||
if (sessionId) candidates.push(sessionId)
|
||||
const myWxid = this.configService.get('myWxid') as string
|
||||
|
||||
// 如果有 senderWxid,优先使用(群聊中最重要)
|
||||
if (senderWxid) {
|
||||
candidates.push(senderWxid)
|
||||
}
|
||||
|
||||
// sessionId(1对1聊天时是对方wxid,群聊时是群id)
|
||||
if (sessionId && !candidates.includes(sessionId)) {
|
||||
candidates.push(sessionId)
|
||||
}
|
||||
|
||||
// 我的wxid(兜底)
|
||||
if (myWxid && !candidates.includes(myWxid)) {
|
||||
candidates.push(myWxid)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 3. 调用 C++ 接口获取语音 (Hex)
|
||||
const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, localId, msgSvrId)
|
||||
if (!voiceRes.success || !voiceRes.hex) {
|
||||
return { success: false, error: voiceRes.error || '未找到语音数据' }
|
||||
const t3 = Date.now()
|
||||
// 从数据库读取 silk 数据
|
||||
const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates)
|
||||
const t4 = Date.now()
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`)
|
||||
|
||||
if (!silkData) {
|
||||
return { success: false, 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) }
|
||||
const t5 = Date.now()
|
||||
// 使用 silk-wasm 解码
|
||||
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||
const t6 = Date.now()
|
||||
console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`)
|
||||
|
||||
if (!pcmData) {
|
||||
return { success: false, error: 'Silk 解码失败' }
|
||||
}
|
||||
|
||||
const t7 = Date.now()
|
||||
// PCM -> WAV
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
const t8 = Date.now()
|
||||
console.log(`[Voice] createWavBuffer: ${t8 - t7}ms`)
|
||||
|
||||
// 缓存 WAV 数据到内存
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
|
||||
// 缓存 WAV 数据到文件(异步,不阻塞返回)
|
||||
this.cacheVoiceWavToFile(cacheKey, wavData)
|
||||
|
||||
console.log(`[Voice] 总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
console.error('ChatService: getVoiceData 失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
@@ -2246,26 +2290,228 @@ class ChatService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查语音是否已有缓存
|
||||
* 缓存 WAV 数据到文件(异步)
|
||||
*/
|
||||
private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise<void> {
|
||||
try {
|
||||
const voiceCacheDir = this.getVoiceCacheDir()
|
||||
if (!existsSync(voiceCacheDir)) {
|
||||
mkdirSync(voiceCacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
|
||||
writeFileSync(wavFilePath, wavData)
|
||||
} catch (e) {
|
||||
console.error('[Voice] 缓存文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口)
|
||||
* 策略:批量查询 + 多种兜底方案
|
||||
*/
|
||||
private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise<Buffer | null> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const t1 = Date.now()
|
||||
// 获取所有 media 数据库(永久缓存,直到应用重启)
|
||||
let mediaDbFiles: string[]
|
||||
if (this.mediaDbsCache) {
|
||||
mediaDbFiles = this.mediaDbsCache
|
||||
console.log(`[Voice] listMediaDbs (缓存): 0ms`)
|
||||
} else {
|
||||
const mediaDbsResult = await wcdbService.listMediaDbs()
|
||||
const t2 = Date.now()
|
||||
console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`)
|
||||
|
||||
if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
mediaDbFiles = mediaDbsResult.data as string[]
|
||||
this.mediaDbsCache = mediaDbFiles // 永久缓存
|
||||
}
|
||||
|
||||
// 在所有 media 数据库中查找
|
||||
for (const dbPath of mediaDbFiles) {
|
||||
try {
|
||||
// 检查缓存
|
||||
let schema = this.mediaDbSchemaCache.get(dbPath)
|
||||
|
||||
if (!schema) {
|
||||
const t3 = Date.now()
|
||||
// 第一次查询,获取表结构并缓存
|
||||
const tablesResult = await wcdbService.execQuery('media', dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'"
|
||||
)
|
||||
const t4 = Date.now()
|
||||
console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`)
|
||||
|
||||
if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const voiceTable = tablesResult.rows[0].name
|
||||
|
||||
const t5 = Date.now()
|
||||
const columnsResult = await wcdbService.execQuery('media', dbPath,
|
||||
`PRAGMA table_info('${voiceTable}')`
|
||||
)
|
||||
const t6 = Date.now()
|
||||
console.log(`[Voice] 查询表结构: ${t6 - t5}ms`)
|
||||
|
||||
if (!columnsResult.success || !columnsResult.rows) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建列名映射(原始名称 -> 小写名称)
|
||||
const columnMap = new Map<string, string>()
|
||||
for (const c of columnsResult.rows) {
|
||||
const name = String(c.name || '')
|
||||
if (name) {
|
||||
columnMap.set(name.toLowerCase(), name)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找数据列(使用原始列名)
|
||||
const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n))
|
||||
const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined
|
||||
|
||||
if (!dataColumn) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 查找 chat_name_id 列
|
||||
const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n))
|
||||
const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined
|
||||
|
||||
// 查找时间列
|
||||
const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n))
|
||||
const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined
|
||||
|
||||
const t7 = Date.now()
|
||||
// 查找 Name2Id 表
|
||||
const name2IdTablesResult = await wcdbService.execQuery('media', dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'"
|
||||
)
|
||||
const t8 = Date.now()
|
||||
console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`)
|
||||
|
||||
const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0)
|
||||
? name2IdTablesResult.rows[0].name
|
||||
: undefined
|
||||
|
||||
schema = {
|
||||
voiceTable,
|
||||
dataColumn,
|
||||
chatNameIdColumn,
|
||||
timeColumn,
|
||||
name2IdTable
|
||||
}
|
||||
|
||||
// 缓存表结构
|
||||
this.mediaDbSchemaCache.set(dbPath, schema)
|
||||
}
|
||||
|
||||
// 策略1: 通过 chat_name_id + create_time 查找(最准确)
|
||||
if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) {
|
||||
const t9 = Date.now()
|
||||
// 批量获取所有 candidates 的 chat_name_id(减少查询次数)
|
||||
const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',')
|
||||
const name2IdResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})`
|
||||
)
|
||||
const t10 = Date.now()
|
||||
console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`)
|
||||
|
||||
if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) {
|
||||
// 构建 chat_name_id 列表
|
||||
const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid)
|
||||
const chatNameIdsStr = chatNameIds.join(',')
|
||||
|
||||
const t11 = Date.now()
|
||||
// 一次查询所有可能的语音
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1`
|
||||
)
|
||||
const t12 = Date.now()
|
||||
console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`)
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: 只通过 create_time 查找(兜底)
|
||||
if (schema.timeColumn) {
|
||||
const t13 = Date.now()
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1`
|
||||
)
|
||||
const t14 = Date.now()
|
||||
console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`)
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况)
|
||||
if (schema.timeColumn) {
|
||||
const t15 = Date.now()
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1`
|
||||
)
|
||||
const t16 = Date.now()
|
||||
console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`)
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,继续尝试下一个数据库
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查语音是否已有缓存(只检查内存,不查询数据库)
|
||||
*/
|
||||
async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> {
|
||||
try {
|
||||
// 直接用 msgId 生成 cacheKey,不查询数据库
|
||||
// 注意:这里的 cacheKey 可能不准确(因为没有 createTime),但只是用来快速检查缓存
|
||||
// 如果缓存未命中,用户点击时会重新用正确的 cacheKey 查询
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
|
||||
|
||||
// 1. 检查内存缓存
|
||||
// 检查内存缓存
|
||||
const inMemory = this.voiceWavCache.get(cacheKey)
|
||||
if (inMemory) {
|
||||
return { success: true, hasCache: true, data: inMemory.toString('base64') }
|
||||
}
|
||||
|
||||
// 2. 检查文件缓存
|
||||
const cachedFile = this.getVoiceCacheFilePath(cacheKey)
|
||||
if (existsSync(cachedFile)) {
|
||||
const wavData = readFileSync(cachedFile)
|
||||
this.cacheVoiceWav(cacheKey, wavData) // 回甜内存
|
||||
return { success: true, hasCache: true, data: wavData.toString('base64') }
|
||||
}
|
||||
|
||||
return { success: true, hasCache: false }
|
||||
} catch (e) {
|
||||
return { success: false, hasCache: false }
|
||||
@@ -2460,60 +2706,133 @@ class ChatService {
|
||||
async getVoiceTranscript(
|
||||
sessionId: string,
|
||||
msgId: string,
|
||||
createTime?: number,
|
||||
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) {
|
||||
return { success: true, transcript: cached }
|
||||
}
|
||||
|
||||
const pending = this.voiceTranscriptPending.get(cacheKey)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
let wavData = this.voiceWavCache.get(cacheKey)
|
||||
if (!wavData) {
|
||||
// 获取消息详情以拿到 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 startTime = Date.now()
|
||||
console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
|
||||
|
||||
try {
|
||||
let msgCreateTime = createTime
|
||||
let serverId: string | number | undefined
|
||||
|
||||
// 如果前端没传 createTime,才需要查询消息(这个很慢)
|
||||
if (!msgCreateTime) {
|
||||
const t1 = Date.now()
|
||||
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
|
||||
const t2 = Date.now()
|
||||
console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`)
|
||||
|
||||
if (msgResult.success && msgResult.message) {
|
||||
msgCreateTime = msgResult.message.createTime
|
||||
serverId = msgResult.message.serverId
|
||||
console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`)
|
||||
}
|
||||
|
||||
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
|
||||
onPartial?.(text)
|
||||
})
|
||||
if (result.success && result.transcript) {
|
||||
this.cacheVoiceTranscript(cacheKey, result.transcript)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
this.voiceTranscriptPending.delete(cacheKey)
|
||||
}
|
||||
})()
|
||||
|
||||
this.voiceTranscriptPending.set(cacheKey, task)
|
||||
return task
|
||||
if (!msgCreateTime) {
|
||||
console.error(`[Transcribe] 未找到消息时间戳`)
|
||||
return { success: false, error: '未找到消息时间戳' }
|
||||
}
|
||||
|
||||
// 使用正确的 cacheKey(包含 createTime)
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime)
|
||||
console.log(`[Transcribe] cacheKey=${cacheKey}`)
|
||||
|
||||
// 检查转写缓存
|
||||
const cached = this.voiceTranscriptCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`[Transcribe] 缓存命中,总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, transcript: cached }
|
||||
}
|
||||
|
||||
// 检查是否正在转写
|
||||
const pending = this.voiceTranscriptPending.get(cacheKey)
|
||||
if (pending) {
|
||||
console.log(`[Transcribe] 正在转写中,等待结果`)
|
||||
return pending
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
// 检查内存中是否有 WAV 数据
|
||||
let wavData = this.voiceWavCache.get(cacheKey)
|
||||
if (wavData) {
|
||||
console.log(`[Transcribe] WAV内存缓存命中,大小: ${wavData.length} bytes`)
|
||||
} else {
|
||||
// 检查文件缓存
|
||||
const voiceCacheDir = this.getVoiceCacheDir()
|
||||
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
|
||||
if (existsSync(wavFilePath)) {
|
||||
try {
|
||||
wavData = readFileSync(wavFilePath)
|
||||
console.log(`[Transcribe] WAV文件缓存命中,大小: ${wavData.length} bytes`)
|
||||
// 同时缓存到内存
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
} catch (e) {
|
||||
console.error(`[Transcribe] 读取缓存文件失败:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!wavData) {
|
||||
console.log(`[Transcribe] WAV缓存未命中,调用 getVoiceData`)
|
||||
const t3 = Date.now()
|
||||
// 调用 getVoiceData 获取并解码
|
||||
const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId)
|
||||
const t4 = Date.now()
|
||||
console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`)
|
||||
|
||||
if (!voiceResult.success || !voiceResult.data) {
|
||||
console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`)
|
||||
return { success: false, error: voiceResult.error || '语音解码失败' }
|
||||
}
|
||||
wavData = Buffer.from(voiceResult.data, 'base64')
|
||||
console.log(`[Transcribe] WAV数据大小: ${wavData.length} bytes`)
|
||||
}
|
||||
|
||||
// 转写
|
||||
console.log(`[Transcribe] 开始调用 transcribeWavBuffer`)
|
||||
const t5 = Date.now()
|
||||
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
|
||||
console.log(`[Transcribe] 部分结果: ${text}`)
|
||||
onPartial?.(text)
|
||||
})
|
||||
const t6 = Date.now()
|
||||
console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`)
|
||||
|
||||
if (result.success && result.transcript) {
|
||||
console.log(`[Transcribe] 转写成功: ${result.transcript}`)
|
||||
this.cacheVoiceTranscript(cacheKey, result.transcript)
|
||||
} else {
|
||||
console.error(`[Transcribe] 转写失败: ${result.error}`)
|
||||
}
|
||||
|
||||
console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`[Transcribe] 异常:`, error)
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
this.voiceTranscriptPending.delete(cacheKey)
|
||||
}
|
||||
})()
|
||||
|
||||
this.voiceTranscriptPending.set(cacheKey, task)
|
||||
return task
|
||||
} catch (error) {
|
||||
console.error(`[Transcribe] 外层异常:`, error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private getVoiceCacheKey(sessionId: string, msgId: string): string {
|
||||
private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string {
|
||||
// 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱
|
||||
if (createTime) {
|
||||
return `${sessionId}_${createTime}`
|
||||
}
|
||||
return `${sessionId}_${msgId}`
|
||||
}
|
||||
|
||||
@@ -2525,32 +2844,6 @@ 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) {
|
||||
@@ -2561,8 +2854,6 @@ class ChatService {
|
||||
|
||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||
try {
|
||||
console.info('[ChatService] getMessageById (SQL)', { sessionId, localId })
|
||||
|
||||
// 1. 获取该会话所在的消息表
|
||||
// 注意:这里使用 getMessageTableStats 而不是 getMessageTables,因为前者包含 db_path
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
@@ -2585,7 +2876,6 @@ class ChatService {
|
||||
const message = this.parseMessage(row)
|
||||
|
||||
if (message.localId !== 0) {
|
||||
console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId })
|
||||
return { success: true, message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,14 +68,7 @@ export class ImageDecryptService {
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
if (meta) {
|
||||
console.info(message, meta)
|
||||
} else {
|
||||
console.info(message)
|
||||
}
|
||||
|
||||
// 写入日志文件
|
||||
// 只写入文件,不输出到控制台
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
|
||||
@@ -224,13 +224,16 @@ export class VoiceTranscribeService {
|
||||
let finalTranscript = ''
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
console.log('[VoiceTranscribe] Worker 消息:', msg)
|
||||
if (msg.type === 'partial') {
|
||||
onPartial?.(msg.text)
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||
resolve({ success: false, error: msg.error })
|
||||
worker.terminate()
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ 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')
|
||||
@@ -620,7 +620,7 @@ export class WcdbCore {
|
||||
try {
|
||||
this.wcdbSetMyWxid(this.handle, wxid)
|
||||
} catch (e) {
|
||||
console.warn('设置 wxid 失败:', e)
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
if (this.isLogEnabled()) {
|
||||
@@ -799,7 +799,6 @@ export class WcdbCore {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
|
||||
if (Object.keys(resultMap).length > 0) {
|
||||
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
|
||||
}
|
||||
@@ -807,25 +806,18 @@ export class WcdbCore {
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) {
|
||||
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
|
||||
return { success: false, error: '解析头像失败' }
|
||||
}
|
||||
const map = JSON.parse(jsonStr) as Record<string, string>
|
||||
let successCount = 0
|
||||
let emptyCount = 0
|
||||
for (const username of toFetch) {
|
||||
const url = map[username]
|
||||
if (url && url.trim()) {
|
||||
resultMap[username] = url
|
||||
// 只缓存有效的URL
|
||||
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
||||
successCount++
|
||||
} else {
|
||||
emptyCount++
|
||||
// 不缓存空URL,下次可以重新尝试
|
||||
}
|
||||
// 不缓存空URL,下次可以重新尝试
|
||||
}
|
||||
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
|
||||
return { success: true, map: resultMap }
|
||||
} catch (e) {
|
||||
console.error('[wcdbCore] getAvatarUrls 异常:', e)
|
||||
|
||||
Reference in New Issue
Block a user