diff --git a/package.json b/package.json index fe87adc..b0d5704 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tracememo", "version": "2.2.0", "packageManager": "pnpm@7.33.7", - "description": "macOS / Windows 本地优先、可追溯的 AI 微信知识与分析工作台", + "description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。", "keywords": [ "wechat", "wechat chat", @@ -20,7 +20,7 @@ "author": "Qingmao", "repository": { "type": "git", - "url": "https://github.com/Wxw-Gu/WechatExplorer.git" + "url": "https://github.com/Wxw-Gu/TraceMemo.git" }, "main": "./out/main/index.js", "scripts": { @@ -135,4 +135,4 @@ "ffmpeg-static" ] } -} +} \ No newline at end of file diff --git a/src/main/app-data-migration.ts b/src/main/app-data-migration.ts index 833cb46..a888017 100644 --- a/src/main/app-data-migration.ts +++ b/src/main/app-data-migration.ts @@ -18,8 +18,10 @@ import { type UserDataSelection } from './app-data-paths' import type { LegacySecretBundle } from './legacy-safe-storage-helper' +import { SqliteTranscriptRepository } from './voice-pipeline/transcript-repository' const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json' +const VOICE_TRANSCRIPT_CACHE_ITEM = 'cache/voice-transcripts.sqlite' const TOKEN_MIGRATION_BLOCK_MESSAGE = '检测到旧版 API Token 尚未完成迁移。请重试数据迁移,或在 API Center 主动重新生成 Token。' @@ -289,6 +291,24 @@ async function copyDirectoryWithoutOverwrite( return 'migrated' } +export async function migrateLegacyVoiceTranscripts( + sourceRoot: string, + targetRoot: string +): Promise { + const relativePath = path.join('cache', 'voice-transcripts.sqlite') + const sourcePath = path.join(sourceRoot, relativePath) + const targetPath = path.join(targetRoot, relativePath) + if (!(await fs.pathExists(sourcePath))) return 'missing' + if (path.resolve(sourcePath) === path.resolve(targetPath)) return 'skipped' + + const repository = new SqliteTranscriptRepository(targetPath) + try { + return repository.mergeFrom(sourcePath) > 0 ? 'migrated' : 'skipped' + } finally { + repository.close() + } +} + async function writeEncryptedWithoutOverwrite( targetRoot: string, relativePath: string, @@ -472,6 +492,9 @@ export async function executeMigration( copyDirectoryWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot) ) } + await runItem(VOICE_TRANSCRIPT_CACHE_ITEM, () => + migrateLegacyVoiceTranscripts(sourceRoot, targetRoot) + ) const agentRoots = dependencies.agentRoots() await runItem('agent-credentials', () => copyMissingTree(agentRoots.legacy, agentRoots.current)) @@ -528,7 +551,9 @@ export async function executeMigration( await fs.remove(stagingRoot).catch(() => undefined) } - const failed = Object.values(items).some((status) => status === 'failed') + const failed = Object.entries(items).some( + ([name, status]) => name !== VOICE_TRANSCRIPT_CACHE_ITEM && status === 'failed' + ) state.status = failed || secretFailures.length > 0 ? 'partial' : 'completed' state.updatedAt = timestamp() state.secretFailures = Array.from(new Set(secretFailures)) @@ -592,6 +617,38 @@ function closeMigrationProgress(window: BrowserWindow | null): void { export async function runFirstLaunchMigration(roots: UserDataRoots): Promise { const assessment = assessMigration(roots) + const voiceMigrationStatus = assessment.state?.items[VOICE_TRANSCRIPT_CACHE_ITEM] + const needsVoiceBackfill = !voiceMigrationStatus + if ( + assessment.reason === 'migration-completed' && + assessment.sourceRoot && + assessment.state && + needsVoiceBackfill + ) { + const state: MigrationState = { + ...assessment.state, + items: { ...assessment.state.items }, + updatedAt: new Date().toISOString() + } + try { + state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = await migrateLegacyVoiceTranscripts( + assessment.sourceRoot, + roots.current + ) + } catch { + state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = 'failed' + } + await writeMigrationState(roots.current, state) + return { + assessment: { ...assessment, state }, + action: state.items[VOICE_TRANSCRIPT_CACHE_ITEM] === 'migrated' ? 'migrated' : 'none', + execution: { + state, + tokenGenerationBlocked: false + }, + tokenGenerationBlocked: false + } + } if (!assessment.shouldPrompt || !assessment.sourceRoot) { return { assessment, action: 'none', tokenGenerationBlocked: false } } @@ -649,9 +706,7 @@ export async function runFirstLaunchMigration(roots: UserDataRoots): Promise isNonEmptyFile(path.join(root, marker)))) return true if (hasKnowledgeDatabase(root)) return true diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index ff91e10..d489142 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -995,6 +995,19 @@ const renderExportScript = (name: string): string => ` message.contentData && forwardedSearchText(message.contentData.items), message.exportMediaName ].filter(Boolean).join(' ').toLowerCase() + const exportedImageOrVideoFileStem = (message) => { + const mediaType = message.exportMediaType || (message.contentData && message.contentData.type) + if (mediaType !== 'image' && mediaType !== 'video') return '' + const mediaUrl = String(message.exportMediaUrl || '').split(/[?#]/, 1)[0] + const fileName = mediaUrl.slice(mediaUrl.lastIndexOf('/') + 1) + if (!fileName) return '' + let decodedFileName = fileName + try { + decodedFileName = decodeURIComponent(fileName) + } catch {} + const extensionIndex = decodedFileName.lastIndexOf('.') + return (extensionIndex > 0 ? decodedFileName.slice(0, extensionIndex) : decodedFileName).toLowerCase() + } const shareLabel = (typeVal) => { if (String(typeVal) === '5') return '公众号链接' @@ -1516,7 +1529,9 @@ const renderExportScript = (name: string): string => ` return allMessages.filter((message) => (activeConversation === 'all' || message.exportConversationId === activeConversation) && (activeKind === 'all' || kindOf(message) === activeKind) && - (!term || searchText(message).includes(term)) + (!term || + searchText(message).includes(term) || + exportedImageOrVideoFileStem(message) === term) ) } const applyFilters = (restorePosition = false) => { @@ -1779,7 +1794,7 @@ export function renderExportPage(name: string): string {
- +
diff --git a/src/main/export-service.ts b/src/main/export-service.ts index a416198..a7fb0dd 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -275,6 +275,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => { if (!current.exportMediaUrl && !current.voiceDataUrl && previous.exportMediaError) { merged.exportMediaError = previous.exportMediaError } + if (merged.voiceDataUrl) delete merged.exportMediaError + if (merged.voiceTranscript) delete merged.voiceTranscriptError return merged } @@ -754,7 +756,8 @@ const preserveLegacyCombinedArchive = async (outputDir: string): Promise = async function runSingleExport( request: ExportRequest, win: BrowserWindow, - voiceRecognition?: Pick, + voiceRecognition?: Pick & + Partial>, options: SingleExportOptions = {} ): Promise { const manageJob = options.manageJob !== false @@ -1083,89 +1086,173 @@ async function runSingleExport( total: voiceMessages.length, percent: 20 }) - for (const [voiceIndex, message] of voiceMessages.entries()) { - if (!jobs.has(request.jobId)) throw new Error('已取消') - const previous = reusablePreviousMessages.get(message) - let canTranscribe = true - if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) { - message.voiceDataUrl = previous.voiceDataUrl - message.voiceDuration = previous.voiceDuration - if (request.includeVoiceTranscripts && previous.voiceTranscript) { - message.voiceTranscript = previous.voiceTranscript + const voiceIndexUpdates = new Map< + string, + { + reference: { + sessionId: string + localId: number + createTime: number + svrId?: string | number } - } else if (!message.sessionId || message.localId == null || !message.createTime) { - keepMediaError(request, message, '语音标识不完整,无法定位本地语音') - canTranscribe = false - } else { - try { - const voice = await voiceService.resolveVoice( - message.sessionId, - message.localId, - message.createTime, - message.serverId - ) - if (!voice.success || !voice.data) { - const detail = voice.error || '未知原因' - const reason = /未找到|不存在|获取语音数据失败/.test(detail) - ? `语音文件缺失:${detail}` - : /Silk|解码|数据为空/.test(detail) - ? `语音解析失败:${detail}` - : `语音格式不支持或读取失败:${detail}` - keepMediaError(request, message, reason) - canTranscribe = false - } else { - const audioBuffer = Buffer.from(voice.data, 'base64') - const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav` - const voiceUrl = `voices/${voiceName}` - if (!(await resourceExists(voiceUrl))) { - await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) - markResourceExists(voiceUrl) + transcript: string + cached: boolean + } + >() + const batchSize = 16 + for (let batchStart = 0; batchStart < voiceMessages.length; batchStart += batchSize) { + const batch = voiceMessages.slice(batchStart, batchStart + batchSize) + const mediaItems: Array<{ + message: Message + reference: { + sessionId: string + localId: number + createTime: number + svrId?: string | number + } + }> = [] + for (const message of batch) { + if (!jobs.has(request.jobId)) throw new Error('已取消') + const previous = reusablePreviousMessages.get(message) + const hasVoiceIdentity = Boolean( + message.sessionId && message.localId != null && message.createTime + ) + const reference = hasVoiceIdentity + ? { + sessionId: message.sessionId!, + localId: message.localId!, + createTime: message.createTime!, + svrId: message.serverId } - message.voiceDataUrl = voiceUrl - message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) + : null + if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) { + message.voiceDataUrl = previous.voiceDataUrl + message.voiceDuration = previous.voiceDuration + if (request.includeVoiceTranscripts && previous.voiceTranscript) { + message.voiceTranscript = previous.voiceTranscript } + } + if (request.includeVoiceTranscripts && !message.voiceTranscript) { + if (!reference) { + message.voiceTranscriptError = '语音标识不完整,无法转文字' + } else if (!voiceRecognition) { + message.voiceTranscriptError = '语音转文字服务不可用' + } else { + try { + const recognition = await voiceRecognition.recognize( + reference, + voiceRecognition.publishTranscript + ? { publishTranscriptUpdate: false } + : undefined + ) + if (recognition.success) { + message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字' + if (voiceRecognition.publishTranscript && recognition.transcript?.trim()) { + voiceIndexUpdates.set(reference.sessionId, { + reference, + transcript: recognition.transcript.trim(), + cached: Boolean(recognition.cached) + }) + } + } else { + message.voiceTranscriptError = recognition.error || '语音识别失败' + } + } catch (error) { + message.voiceTranscriptError = + error instanceof Error ? error.message : '语音识别失败' + } + } + } + if ( + request.includeVoiceTranscripts && + reference && + message.voiceTranscript && + voiceRecognition?.publishTranscript && + !voiceIndexUpdates.has(reference.sessionId) + ) { + voiceIndexUpdates.set(reference.sessionId, { + reference, + transcript: message.voiceTranscript, + cached: true + }) + } + if (!message.voiceDataUrl) { + if (!reference) { + keepMediaError(request, message, '语音标识不完整,无法定位本地语音') + } else { + mediaItems.push({ message, reference }) + } + } + } + + const voices = + typeof voiceService.resolveVoices === 'function' + ? await voiceService.resolveVoices(mediaItems.map((item) => item.reference)) + : await Promise.all( + mediaItems.map(({ reference }) => + voiceService.resolveVoice( + reference.sessionId, + reference.localId, + reference.createTime, + reference.svrId + ) + ) + ) + for (const [{ message }, voice] of mediaItems.map( + (item, index) => [item, voices[index]] as const + )) { + if (!voice?.success || !voice.data) { + const detail = voice?.error || '未知原因' + const reason = /未找到|不存在|获取语音数据失败/.test(detail) + ? `语音文件缺失:${detail}` + : /Silk|解码|数据为空/.test(detail) + ? `语音解析失败:${detail}` + : `语音格式不支持或读取失败:${detail}` + keepMediaError(request, message, reason) + continue + } + try { + const audioBuffer = Buffer.from(voice.data, 'base64') + const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav` + const voiceUrl = `voices/${voiceName}` + if (!(await resourceExists(voiceUrl))) { + await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) + markResourceExists(voiceUrl) + } + message.voiceDataUrl = voiceUrl + message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) } catch (error) { keepMediaError( request, message, `语音文件写入失败:${error instanceof Error ? error.message : String(error)}` ) - canTranscribe = false - } - } - if (request.includeVoiceTranscripts && canTranscribe && !message.voiceTranscript) { - if (!voiceRecognition) { - message.voiceTranscriptError = '语音转文字服务不可用' - } else { - try { - const recognition = await voiceRecognition.recognize({ - sessionId: message.sessionId!, - localId: message.localId!, - createTime: message.createTime!, - svrId: message.serverId - }) - if (recognition.success) { - message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字' - } else { - message.voiceTranscriptError = recognition.error || '语音识别失败' - } - } catch (error) { - message.voiceTranscriptError = - error instanceof Error ? error.message : '语音识别失败' - } } } + const processedVoices = Math.min(batchStart + batch.length, voiceMessages.length) send({ jobId: request.jobId, phase: voicePhase, - processed: voiceIndex + 1, + processed: processedVoices, total: voiceMessages.length, percent: 20 + Math.round( - ((voiceIndex + 1) / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20) + (processedVoices / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20) ) }) + await new Promise((resolve) => setImmediate(resolve)) + } + for (const update of voiceIndexUpdates.values()) { + try { + await voiceRecognition?.publishTranscript?.( + update.reference, + update.transcript, + update.cached + ) + } catch (error) { + console.warn('[Export] voice transcript index refresh failed:', error) + } } } else if (request.includeMedia) { for (const message of messages) { @@ -1500,7 +1587,8 @@ async function runSingleExport( async function runAllExport( request: ExportRequest, win: BrowserWindow, - voiceRecognition?: Pick + voiceRecognition?: Pick & + Partial> ): Promise { jobs.add(request.jobId) const send = (progress: ExportJobProgress): void => { @@ -1674,7 +1762,8 @@ async function runAllExport( export async function runExport( request: ExportRequest, win: BrowserWindow, - voiceRecognition?: Pick + voiceRecognition?: Pick & + Partial> ): Promise { return request.scope === 'all' ? runAllExport(request, win, voiceRecognition) diff --git a/src/main/voice-pipeline/audio-processor.ts b/src/main/voice-pipeline/audio-processor.ts index a9b2551..7c1aeb0 100644 --- a/src/main/voice-pipeline/audio-processor.ts +++ b/src/main/voice-pipeline/audio-processor.ts @@ -10,6 +10,7 @@ export interface PcmProcessorOptions { } export class PcmAudioProcessor implements AudioProcessor { + readonly version = VOICE_PROCESSOR_VERSION private readonly targetSampleRate: number private readonly silenceThreshold: number private readonly silencePaddingMs: number diff --git a/src/main/voice-pipeline/transcript-repository.ts b/src/main/voice-pipeline/transcript-repository.ts index 7979b0b..bbe2a2d 100644 --- a/src/main/voice-pipeline/transcript-repository.ts +++ b/src/main/voice-pipeline/transcript-repository.ts @@ -1,17 +1,40 @@ import { dirname } from 'path' import { mkdirSync } from 'fs' import { DatabaseSync } from 'node:sqlite' -import type { - TranscriptMessageStatus, - TranscriptRecord, - TranscriptRepository -} from './types' +import type { TranscriptMessageStatus, TranscriptRecord, TranscriptRepository } from './types' type TranscriptKey = Omit< TranscriptRecord, 'transcript' | 'language' | 'durationMs' | 'createdAt' | 'updatedAt' > +type CompatibleTranscriptKey = Pick< + TranscriptRecord, + | 'accountId' + | 'messageIdentity' + | 'processorVersion' + | 'recognizerId' + | 'modelVersion' + | 'modelFingerprint' +> + +function transcriptRecord(row: Record): TranscriptRecord { + return { + accountId: String(row.account_id), + messageIdentity: String(row.message_identity), + audioHash: String(row.audio_hash), + processorVersion: String(row.processor_version), + recognizerId: String(row.recognizer_id), + modelVersion: String(row.model_version), + modelFingerprint: String(row.model_fingerprint), + transcript: String(row.transcript), + language: row.language ? String(row.language) : undefined, + durationMs: Number(row.duration_ms), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + } +} + export class SqliteTranscriptRepository implements TranscriptRepository { private readonly database: DatabaseSync @@ -70,20 +93,7 @@ export class SqliteTranscriptRepository implements TranscriptRepository { key.modelFingerprint ) as Record | undefined if (!row) return null - return { - accountId: String(row.account_id), - messageIdentity: String(row.message_identity), - audioHash: String(row.audio_hash), - processorVersion: String(row.processor_version), - recognizerId: String(row.recognizer_id), - modelVersion: String(row.model_version), - modelFingerprint: String(row.model_fingerprint), - transcript: String(row.transcript), - language: row.language ? String(row.language) : undefined, - durationMs: Number(row.duration_ms), - createdAt: Number(row.created_at), - updatedAt: Number(row.updated_at) - } + return transcriptRecord(row) } findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null { @@ -99,19 +109,86 @@ export class SqliteTranscriptRepository implements TranscriptRepository { ) .get(accountId, messageIdentity) as Record | undefined if (!row) return null - return { - accountId: String(row.account_id), - messageIdentity: String(row.message_identity), - audioHash: String(row.audio_hash), - processorVersion: String(row.processor_version), - recognizerId: String(row.recognizer_id), - modelVersion: String(row.model_version), - modelFingerprint: String(row.model_fingerprint), - transcript: String(row.transcript), - language: row.language ? String(row.language) : undefined, - durationMs: Number(row.duration_ms), - createdAt: Number(row.created_at), - updatedAt: Number(row.updated_at) + return transcriptRecord(row) + } + + findCompatible(key: CompatibleTranscriptKey): TranscriptRecord | null { + const row = this.database + .prepare( + `SELECT account_id, message_identity, audio_hash, processor_version, + recognizer_id, model_version, model_fingerprint, transcript, + language, duration_ms, created_at, updated_at + FROM voice_transcripts + WHERE account_id = ? AND message_identity = ? AND processor_version = ? + AND recognizer_id = ? AND model_version = ? AND model_fingerprint = ? + AND trim(transcript) <> '' + ORDER BY updated_at DESC + LIMIT 1` + ) + .get( + key.accountId, + key.messageIdentity, + key.processorVersion, + key.recognizerId, + key.modelVersion, + key.modelFingerprint + ) as Record | undefined + return row ? transcriptRecord(row) : null + } + + mergeFrom(databasePath: string): number { + this.database.prepare('ATTACH DATABASE ? AS legacy_voice').run(databasePath) + try { + const hasTranscripts = this.database + .prepare( + `SELECT 1 FROM legacy_voice.sqlite_master + WHERE type = 'table' AND name = 'voice_transcripts'` + ) + .get() + if (!hasTranscripts) + throw new Error('Legacy voice transcript database has no transcript table') + + this.database.exec('BEGIN IMMEDIATE') + try { + this.database.exec(` + INSERT OR IGNORE INTO main.voice_transcripts ( + account_id, message_identity, audio_hash, processor_version, + recognizer_id, model_version, model_fingerprint, transcript, + language, duration_ms, created_at, updated_at + ) + SELECT account_id, message_identity, audio_hash, processor_version, + recognizer_id, model_version, model_fingerprint, transcript, + language, duration_ms, created_at, updated_at + FROM legacy_voice.voice_transcripts + WHERE trim(transcript) <> '' + `) + const inserted = Number( + ( + this.database.prepare('SELECT changes() AS count').get() as + | Record + | undefined + )?.count || 0 + ) + this.database.exec(` + INSERT INTO main.voice_transcript_message_states ( + account_id, message_identity, state, error, updated_at + ) + SELECT account_id, message_identity, 'transcribed', NULL, MAX(updated_at) + FROM legacy_voice.voice_transcripts + WHERE trim(transcript) <> '' + GROUP BY account_id, message_identity + ON CONFLICT (account_id, message_identity) DO UPDATE SET + state = 'transcribed', error = NULL, updated_at = excluded.updated_at + WHERE excluded.updated_at > voice_transcript_message_states.updated_at + `) + this.database.exec('COMMIT') + return inserted + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } + } finally { + this.database.exec('DETACH DATABASE legacy_voice') } } diff --git a/src/main/voice-pipeline/types.ts b/src/main/voice-pipeline/types.ts index 94bfe25..87d966e 100644 --- a/src/main/voice-pipeline/types.ts +++ b/src/main/voice-pipeline/types.ts @@ -43,6 +43,7 @@ export class SpeechRecognizerRegistry { } export interface AudioProcessor { + readonly version: string process(input: { pcm: Buffer sampleRate: number @@ -87,6 +88,17 @@ export interface TranscriptRepository { > ): TranscriptRecord | null findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null + findCompatible( + key: Pick< + TranscriptRecord, + | 'accountId' + | 'messageIdentity' + | 'processorVersion' + | 'recognizerId' + | 'modelVersion' + | 'modelFingerprint' + > + ): TranscriptRecord | null getMessageStatus(accountId: string, messageIdentity: string): TranscriptMessageStatus save(record: TranscriptRecord): void markFailure(accountId: string, messageIdentity: string, error: string): void diff --git a/src/main/voice-pipeline/voice-pipeline.ts b/src/main/voice-pipeline/voice-pipeline.ts index 0dcaff0..e7f13fc 100644 --- a/src/main/voice-pipeline/voice-pipeline.ts +++ b/src/main/voice-pipeline/voice-pipeline.ts @@ -39,13 +39,28 @@ export class VoicePipeline { reference: VoiceMessageReference, signal?: AbortSignal ): Promise<{ transcript: string; language?: string; durationMs: number; cached: boolean }> { + const messageIdentity = voiceMessageIdentity(reference) + const compatible = this.transcripts.findCompatible({ + accountId, + messageIdentity, + processorVersion: this.audioProcessor.version, + ...this.recognizer.metadata + }) + if (compatible?.transcript.trim()) { + return { + transcript: compatible.transcript.trim(), + language: compatible.language, + durationMs: compatible.durationMs, + cached: true + } + } + const source = await this.sourceResolver.resolve(reference) if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError') const decoded = await this.decoderRegistry.decode(source) if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError') const audio = this.audioProcessor.process(decoded) if (audio.samples.length === 0) throw new Error('Voice audio is empty after processing') - const messageIdentity = voiceMessageIdentity(reference) const key = { accountId, messageIdentity, diff --git a/src/main/voice-pipeline/voice-recognition-use-case.ts b/src/main/voice-pipeline/voice-recognition-use-case.ts index 89790c9..c728329 100644 --- a/src/main/voice-pipeline/voice-recognition-use-case.ts +++ b/src/main/voice-pipeline/voice-recognition-use-case.ts @@ -97,36 +97,44 @@ export class VoiceRecognitionUseCase { const generation = this.accountGeneration const accountIdentity = this.accountId return this.scheduler - .schedule(key, async (signal) => { - const status = await this.modelManager.getStatus() - if (status.state !== 'ready') { - return { success: false, code: 'MODEL_NOT_READY', error: '请先下载语音识别模型' } as const - } - const result = await pipeline.run(accountId, reference, signal) - if (signal.aborted || !this.isCurrentAccount(accountId, generation)) { - throw new DOMException('Recognition cancelled', 'AbortError') - } - const transcript = result.transcript.trim() - if ( - transcript && - options?.publishTranscriptUpdate !== false && - this.isCurrentAccount(accountId, generation) - ) { - try { - await this.publishTranscriptUpdate({ - accountIdentity, - reference, - messageIdentity: voiceMessageIdentity(reference), - state: 'transcribed', - transcript, - cached: result.cached - }) - } catch (error) { - console.warn('[Voice] transcript indexed asynchronously failed:', error) + .schedule( + key, + async (signal) => { + const status = await this.modelManager.getStatus() + if (status.state !== 'ready') { + return { + success: false, + code: 'MODEL_NOT_READY', + error: '请先下载语音识别模型' + } as const } - } - return { success: true, ...result, transcript } as const - }, { priority: options?.priority }) + const result = await pipeline.run(accountId, reference, signal) + if (signal.aborted || !this.isCurrentAccount(accountId, generation)) { + throw new DOMException('Recognition cancelled', 'AbortError') + } + const transcript = result.transcript.trim() + if ( + transcript && + options?.publishTranscriptUpdate !== false && + this.isCurrentAccount(accountId, generation) + ) { + try { + await this.publishTranscriptUpdate({ + accountIdentity, + reference, + messageIdentity: voiceMessageIdentity(reference), + state: 'transcribed', + transcript, + cached: result.cached + }) + } catch (error) { + console.warn('[Voice] transcript indexed asynchronously failed:', error) + } + } + return { success: true, ...result, transcript } as const + }, + { priority: options?.priority } + ) .catch((error): VoiceRecognitionResult => { if (error instanceof DOMException && error.name === 'AbortError') { return { success: false, code: 'CANCELLED', error: '语音识别已取消' } @@ -173,18 +181,25 @@ export class VoiceRecognitionUseCase { } async publishTranscriptSnapshot(reference: VoiceMessageReference): Promise { - const accountIdentity = this.accountId - if (!accountIdentity) return const snapshot = this.getTranscriptSnapshot(reference) if (snapshot.state === 'pending') return + await this.publishTranscript(reference, snapshot.transcript, snapshot.state === 'transcribed') + } + + async publishTranscript( + reference: VoiceMessageReference, + transcript?: string, + cached = true + ): Promise { + const accountIdentity = this.accountId + if (!accountIdentity || !transcript?.trim()) return await this.publishTranscriptUpdate({ accountIdentity, reference, messageIdentity: voiceMessageIdentity(reference), - state: snapshot.state, - transcript: snapshot.transcript, - error: snapshot.error, - cached: snapshot.state === 'transcribed' + state: 'transcribed', + transcript: transcript.trim(), + cached }) } diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index 1358d59..f63a648 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -27,6 +27,13 @@ export type ResolveSourceResult = | { success: true; source: EncodedVoiceSource } | { success: false; error: string } +export interface VoiceReference { + sessionId: string + localId: number + createTime: number + svrId?: string | number +} + export class VoiceService { private wcdb4Client: Wcdb4Client private voiceCache = new Map() @@ -70,6 +77,83 @@ export class VoiceService { return { success: true, data: base64Data } } + async resolveVoices( + references: VoiceReference[] + ): Promise> { + const results: Array<{ success: boolean; data?: string; error?: string } | undefined> = + new Array(references.length) + const missing: Array<{ index: number; reference: VoiceReference }> = [] + references.forEach((reference, index) => { + const cached = this.voiceCache.get( + this.buildCacheKey(reference.sessionId, reference.localId, reference.createTime) + ) + if (cached) results[index] = { success: true, data: cached } + else missing.push({ index, reference }) + }) + if (!missing.length) + return results as Array<{ success: boolean; data?: string; error?: string }> + + const sources = await this.wcdb4Client.getVoiceDataBatch( + missing.map(({ reference }) => ({ + sessionId: reference.sessionId, + createTime: reference.createTime, + localId: reference.localId, + svrId: reference.svrId, + candidates: this.buildCandidates(reference.sessionId) + })) + ) + const failed: Array<{ index: number; reference: VoiceReference }> = [] + for (const [{ index, reference }, source] of missing.map( + (item, sourceIndex) => [item, sources[sourceIndex]] as const + )) { + if (!source?.success || !source.hex) { + failed.push({ index, reference }) + continue + } + const silkData = this.decodeVoiceBlob(source.hex) + if (!silkData?.length) { + results[index] = { success: false, error: '语音数据为空' } + continue + } + try { + const decoded = await this.decoderRegistry.decode({ + data: silkData, + codec: 'silk', + sourceHash: createHash('sha256').update(silkData).digest('hex') + }) + const wavData = this.createWavBuffer(decoded.pcm, 24000) + const data = wavData.toString('base64') + const cacheKey = this.buildCacheKey( + reference.sessionId, + reference.localId, + reference.createTime + ) + this.voiceCache.set(cacheKey, data) + this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' }) + results[index] = { success: true, data } + } catch (error) { + results[index] = { + success: false, + error: error instanceof Error ? error.message : 'Silk 解码失败' + } + } + } + const retried = await Promise.all( + failed.map(({ reference }) => + this.resolveVoice( + reference.sessionId, + reference.localId, + reference.createTime, + reference.svrId + ) + ) + ) + failed.forEach(({ index }, retryIndex) => { + results[index] = retried[retryIndex] + }) + return results as Array<{ success: boolean; data?: string; error?: string }> + } + async resolvePcm( sessionId: string, localId: number, diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index ec737c0..371e47c 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -250,6 +250,20 @@ type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & { type WcdbVoidOut = [unknown] type WcdbHandleOut = [number] +export interface Wcdb4VoiceDataRequest { + sessionId: string + createTime: number + candidates: string[] + localId?: number + svrId?: string | number +} + +export interface Wcdb4VoiceDataResult { + success: boolean + hex?: string + error: string +} + const nodeRequire = createRequire(import.meta.url) function isAsciiPath(value: string): boolean { @@ -409,6 +423,7 @@ export class Wcdb4Client { outHex: WcdbVoidOut ) => number) | null = null + private wcdbGetVoiceDataBatch: KoffiAsyncFunction | null = null private wcdbResolveImageHardlink: | ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number) | null = null @@ -1953,32 +1968,104 @@ export class Wcdb4Client { } const handle = this.ensureHandle() - const outHex: WcdbVoidOut = [null] - - try { - const result = this.wcdbGetVoiceData( + const fn = this.wcdbGetVoiceData as unknown as KoffiAsyncFunction + return this.createTrackedNativeCall((resolve, reject) => { + const outHex: WcdbVoidOut = [null] + fn.async( handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), - outHex + outHex, + (error: unknown, code: unknown) => { + if (error) { + reject(error) + return + } + try { + const result = Number(code) + if (result !== 0 || !outHex[0]) { + resolve({ success: false, error: `获取语音数据失败: ${result}` }) + return + } + const hex = this.decodeHexPtr(outHex[0]) + resolve( + hex === null + ? { success: false, error: '解析语音数据失败' } + : { success: true, hex, error: '' } + ) + } finally { + this.wcdbFreeString?.(outHex[0]) + } + } ) + }) + } - if (result !== 0 || !outHex[0]) { - return { success: false, error: `获取语音数据失败: ${result}` } - } - - const hex = this.decodeHexPtr(outHex[0]) - if (hex === null) { - return { success: false, error: '解析语音数据失败' } - } - - return { success: true, hex, error: '' } - } finally { - this.wcdbFreeString?.(outHex[0]) + async getVoiceDataBatch(requests: Wcdb4VoiceDataRequest[]): Promise { + if (!requests.length) return [] + if (!this.wcdbGetVoiceDataBatch) { + return this.getVoiceDataIndividually(requests) } + + try { + const rows = await this.callJsonAsync[]>( + this.wcdbGetVoiceDataBatch, + JSON.stringify( + requests.map((request, index) => ({ + session_id: request.sessionId, + create_time: request.createTime, + local_id: request.localId || 0, + svr_id: String(request.svrId || 0), + candidates: request.candidates, + index + })) + ) + ) + const byIndex = new Map( + (Array.isArray(rows) ? rows : []).map((row) => [Number(row.index), row]) + ) + return requests.map((_, index) => { + const row = byIndex.get(index) + const rawHex = row?.hex ?? row?.data + const rawSuccess = row?.success ?? row?.Success + const hex = typeof rawHex === 'string' ? rawHex : undefined + const success = (rawSuccess === true || Number(rawSuccess) === 1) && Boolean(hex) + return { + success, + hex: success ? hex : undefined, + error: + typeof row?.error === 'string' && row.error + ? row.error + : success + ? '' + : '获取语音数据失败' + } + }) + } catch (error) { + console.warn('[WCDB4] batch voice lookup failed, using single-item fallback:', error) + return this.getVoiceDataIndividually(requests) + } + } + + private async getVoiceDataIndividually( + requests: Wcdb4VoiceDataRequest[] + ): Promise { + const results: Wcdb4VoiceDataResult[] = [] + for (const request of requests) { + results.push( + await this.getVoiceData( + request.sessionId, + request.createTime, + request.candidates, + request.localId, + request.svrId + ) + ) + } + return results } private decodeHexPtr(ptr: unknown): string | null { @@ -2279,6 +2366,14 @@ export class Wcdb4Client { this.wcdbGetVoiceData = null } + try { + this.wcdbGetVoiceDataBatch = lib.func( + 'int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)' + ) + } catch { + this.wcdbGetVoiceDataBatch = null + } + try { this.wcdbResolveImageHardlink = lib.func( 'int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)' diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index 3f75ccc..05b52d8 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -43,6 +43,7 @@ const state = vi.hoisted(() => ({ } >, voiceLookups: [] as number[], + voiceBatches: [] as number[][], videoLookups: [] as { createTime?: number byteLength?: number @@ -137,6 +138,13 @@ vi.mock('../../src/main/voice-service', () => ({ } : { success: false, error: '本地未找到语音数据' } } + + async resolveVoices( + references: Array<{ localId: number }> + ): Promise> { + state.voiceBatches.push(references.map((reference) => reference.localId)) + return Promise.all(references.map((reference) => this.resolveVoice('', reference.localId))) + } } })) vi.mock('../../src/main/image-decrypt-service', () => ({ @@ -264,6 +272,7 @@ describe('media export flow', () => { state.groupSnapshotReads = [] state.groupSnapshots = {} state.voiceLookups = [] + state.voiceBatches = [] const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08') mkdirSync(fileMonth, { recursive: true }) writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容') @@ -391,7 +400,10 @@ describe('media export flow', () => { send: (_channel: string, payload: (typeof progress)[number]) => progress.push(payload) } } - const recognize = vi.fn(async () => ({ success: true as const, transcript: '固定转写文本' })) + const recognize = vi.fn(async () => { + expect(state.voiceLookups).toEqual([]) + return { success: true as const, transcript: '固定转写文本' } + }) const result = await runExport( { @@ -423,6 +435,88 @@ describe('media export flow', () => { }) }) + it('uses compatible transcript results and waits for one coalesced knowledge update per chat', async () => { + const { runExport } = await import('../../src/main/export-service') + state.messages = [ + message({ + id: 'voice-cache-a', + type: '语音', + sessionId: 'fixture-session', + localId: 1, + createTime: 1_785_549_600, + contentData: { type: 'voice', duration: 1 } + }), + message({ + id: 'voice-cache-b', + type: '语音', + sessionId: 'fixture-session', + localId: 2, + createTime: 1_785_549_601, + contentData: { type: 'voice', duration: 1 } + }) + ] + const recognize = vi.fn(async (reference: { localId: number }) => ({ + success: true as const, + transcript: `缓存文字-${reference.localId}`, + cached: true + })) + const publishTranscript = vi.fn().mockResolvedValue(undefined) + + const result = await runExport( + { + jobId: 'voice-cache-coalesced-index', + targets: [target()], + format: 'html', + outputName: 'voice-cache-coalesced-index', + kinds: ['voice'], + includeMedia: true, + includeVoiceTranscripts: true + }, + { isDestroyed: () => true, webContents: { send: vi.fn() } } as never, + { recognize, publishTranscript } + ) + + expect(result.success, result.error).toBe(true) + expect(recognize).toHaveBeenCalledTimes(2) + expect(recognize).toHaveBeenNthCalledWith(1, expect.objectContaining({ localId: 1 }), { + publishTranscriptUpdate: false + }) + expect(publishTranscript).toHaveBeenCalledOnce() + expect(publishTranscript).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'fixture-session', localId: 2 }), + '缓存文字-2', + true + ) + expect(state.voiceBatches).toEqual([[1, 2]]) + expect(readArchive(result.outputPath!).messages.map((item) => item.voiceTranscript)).toEqual([ + '缓存文字-1', + '缓存文字-2' + ]) + }) + + it('clears stale missing errors when an incremental merge restores playable voice data', async () => { + const { mergeHtmlArchiveMessages } = await import('../../src/main/export-service') + const previous = message({ + id: 'voice-incremental', + type: '语音', + voiceDataUrl: 'voices/existing.wav', + voiceTranscript: '已有转写', + voiceTranscriptError: '旧转写错误' + }) + const current = message({ + id: 'voice-incremental', + type: '语音', + exportMediaError: '语音文件缺失:获取语音数据失败' + }) + + const [merged] = mergeHtmlArchiveMessages([previous], [current]) + + expect(merged.voiceDataUrl).toBe('voices/existing.wav') + expect(merged.voiceTranscript).toBe('已有转写') + expect(merged.exportMediaError).toBeUndefined() + expect(merged.voiceTranscriptError).toBeUndefined() + }) + it('uses the customized file name as the HTML archive title', async () => { const { runExport } = await import('../../src/main/export-service') const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } diff --git a/tests/unit/app-data-migration.test.ts b/tests/unit/app-data-migration.test.ts index ed81ad2..c4a69ce 100644 --- a/tests/unit/app-data-migration.test.ts +++ b/tests/unit/app-data-migration.test.ts @@ -30,8 +30,15 @@ vi.mock('electron', () => ({ } })) -import { assessMigration, executeMigration } from '../../src/main/app-data-migration' -import { getUserDataRoots } from '../../src/main/app-data-paths' +import { + assessMigration, + executeMigration, + migrateLegacyVoiceTranscripts, + runFirstLaunchMigration +} from '../../src/main/app-data-migration' +import { getUserDataRoots, type UserDataRoots } from '../../src/main/app-data-paths' +import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository' +import type { TranscriptRecord } from '../../src/main/voice-pipeline/types' let root = '' @@ -43,7 +50,7 @@ afterEach(() => { fs.removeSync(root) }) -function roots() { +function roots(): UserDataRoots { return getUserDataRoots(path.join(root, 'Application Support')) } @@ -72,6 +79,16 @@ describe('TraceMemo app data migration', () => { }) }) + it('recognizes a legacy voice transcript cache as user-owned data', () => { + const fixture = roots() + writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite')) + expect(assessMigration(fixture)).toMatchObject({ + shouldPrompt: true, + reason: 'legacy-assets-detected', + sourceRoot: fixture.legacy + }) + }) + it('detects legacy settings but never proposes overwriting valid TraceMemo data', () => { const fixture = roots() writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}') @@ -180,4 +197,113 @@ describe('TraceMemo app data migration', () => { ) expect(fs.readFileSync(path.join(fixture.legacy, 'settings.json'), 'utf8')).toContain('legacy') }) + + it('supplements legacy voice transcripts into an existing TraceMemo cache', async () => { + const fixture = roots() + const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite') + const currentPath = path.join(fixture.current, 'cache', 'voice-transcripts.sqlite') + const record: TranscriptRecord = { + accountId: 'account-a', + messageIdentity: 'message-a', + audioHash: 'audio-a', + processorVersion: 'processor-v1', + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a', + transcript: '已经转写过的文字', + durationMs: 800, + createdAt: 1, + updatedAt: 1 + } + const legacy = new SqliteTranscriptRepository(legacyPath) + legacy.save(record) + legacy.close() + + expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('migrated') + expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('skipped') + const current = new SqliteTranscriptRepository(currentPath) + expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('已经转写过的文字') + current.close() + }) + + it('backfills voice transcripts after the original migration was already completed', async () => { + const fixture = roots() + const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite') + const legacy = new SqliteTranscriptRepository(legacyPath) + legacy.save({ + accountId: 'account-a', + messageIdentity: 'message-a', + audioHash: 'audio-a', + processorVersion: 'processor-v1', + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a', + transcript: '补迁文字', + durationMs: 800, + createdAt: 1, + updatedAt: 1 + }) + legacy.close() + fs.ensureDirSync(fixture.current) + fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), { + version: 1, + status: 'completed', + sourceRoot: fixture.legacy, + updatedAt: '2026-08-11T00:00:00.000Z', + items: { 'settings.json': 'migrated' }, + secretFailures: [] + }) + + const result = await runFirstLaunchMigration(fixture) + + expect(result.action).toBe('migrated') + expect(result.execution?.state.items['cache/voice-transcripts.sqlite']).toBe('migrated') + expect(result.execution?.state.status).toBe('completed') + const current = new SqliteTranscriptRepository( + path.join(fixture.current, 'cache', 'voice-transcripts.sqlite') + ) + expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('补迁文字') + current.close() + }) + + it('does not retry a voice transcript backfill after it was marked failed', async () => { + const fixture = roots() + writeFixture(path.join(fixture.legacy, 'settings.json'), '{}') + writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite') + fs.ensureDirSync(fixture.current) + fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), { + version: 1, + status: 'completed', + sourceRoot: fixture.legacy, + updatedAt: '2026-08-11T00:00:00.000Z', + items: { 'cache/voice-transcripts.sqlite': 'failed' }, + secretFailures: [] + }) + + const result = await runFirstLaunchMigration(fixture) + + expect(result.action).toBe('none') + expect(result.execution).toBeUndefined() + expect(fs.existsSync(path.join(fixture.current, 'cache', 'voice-transcripts.sqlite'))).toBe( + false + ) + }) + + it('treats a failed voice transcript migration as a non-blocking downgrade', async () => { + const fixture = roots() + writeFixture(path.join(fixture.legacy, 'settings.json'), '{}') + writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite') + + const result = await executeMigration(fixture.legacy, fixture.current, { + decryptLegacySecrets: async () => ({ databaseKeys: {}, failures: [] }), + agentRoots: () => ({ + legacy: path.join(root, 'agent-legacy'), + current: path.join(root, 'agent-current') + }), + now: () => new Date('2026-08-11T00:00:00.000Z') + }) + + expect(result.state.status).toBe('completed') + expect(result.state.items['cache/voice-transcripts.sqlite']).toBe('failed') + }) }) diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index 7e0f810..876bf43 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -33,7 +33,7 @@ describe('export media', () => { expect(html).toContain('aria-expanded="') expect(html).toContain('setExpandedTimelineYear') expect(html).toContain('data-kind="media"') - expect(html).toContain('placeholder="搜索发送者或消息内容…"') + expect(html).toContain('placeholder="搜索发送者、消息内容或媒体文件名(不含后缀)…"') expect(html).toContain('font-size: 16px;') expect(html).toContain('filtered.slice(windowStart, windowEnd)') expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)') @@ -156,6 +156,61 @@ describe('export media', () => { dom.window.close() }) + it('matches exported image and video filenames exactly without their extension', () => { + const html = renderExportPage('媒体文件名搜索') + const dom = new JSDOM(html, { runScripts: 'outside-only' }) + const imageFileName = 'image_0123456789abcdef.jpg' + const videoFileName = 'video_fedcba9876543210.mp4' + const messages: Message[] = [ + { + ...messageForArchive('image-name', 'fixture', '媒体文件名搜索', '', 1), + type: '图片', + exportMediaType: 'image', + exportMediaUrl: `media/${imageFileName}`, + contentData: { type: 'image' } + }, + { + ...messageForArchive('video-name', 'fixture', '媒体文件名搜索', '', 2), + type: '视频', + exportMediaType: 'video', + exportMediaUrl: `media/${videoFileName}`, + contentData: { type: 'video' } + } + ] + Object.assign(dom.window, { + __WECHAT_EXPORT__: { + version: 1, + sourceId: 'fixture', + name: '媒体文件名搜索', + messages + } + }) + + dom.window.eval(inlineScriptOf(html)) + const search = dom.window.document.querySelector('#query') as HTMLInputElement + + search.value = 'IMAGE_0123456789ABCDEF' + search.dispatchEvent(new dom.window.Event('input')) + expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1) + expect(dom.window.document.querySelectorAll('img.media-image')).toHaveLength(1) + expect(dom.window.document.querySelectorAll('video.media-image')).toHaveLength(0) + + search.value = '0123456789abcdef' + search.dispatchEvent(new dom.window.Event('input')) + expect(dom.window.document.querySelectorAll('.message')).toHaveLength(0) + + search.value = 'video_fedcba9876543210' + search.dispatchEvent(new dom.window.Event('input')) + expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1) + expect(dom.window.document.querySelectorAll('img.media-image')).toHaveLength(0) + expect(dom.window.document.querySelectorAll('video.media-image')).toHaveLength(1) + + search.value = videoFileName + search.dispatchEvent(new dom.window.Event('input')) + expect(dom.window.document.querySelectorAll('.message')).toHaveLength(0) + dom.window.close() + }) + it('filters a v2 merged archive by conversation before search and month counts', () => { const html = renderExportPage('合并档案') const dom = new JSDOM(html, { runScripts: 'outside-only' }) diff --git a/tests/unit/voice-pipeline.test.ts b/tests/unit/voice-pipeline.test.ts index 605d4c2..22d22f2 100644 --- a/tests/unit/voice-pipeline.test.ts +++ b/tests/unit/voice-pipeline.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'os' import { join } from 'path' import { afterAll, describe, expect, it, vi } from 'vitest' import { PcmAudioProcessor } from '../../src/main/voice-pipeline/audio-processor' +import { VoicePipeline } from '../../src/main/voice-pipeline/voice-pipeline' +import { voiceMessageIdentity } from '../../src/main/voice-pipeline/voice-message-identity' import { VoiceTaskScheduler } from '../../src/main/voice-pipeline/task-scheduler' import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository' import type { TranscriptRecord } from '../../src/main/voice-pipeline/types' @@ -114,9 +116,13 @@ describe('voice task scheduling', () => { releaseFirst = resolve }) ) - const background = scheduler.schedule('background', async () => { - order.push('background') - }, { priority: 'background' }) + const background = scheduler.schedule( + 'background', + async () => { + order.push('background') + }, + { priority: 'background' } + ) const interactive = scheduler.schedule('interactive', async () => { order.push('interactive') }) @@ -134,7 +140,9 @@ describe('voice task scheduling', () => { 'background', async (signal) => { order.push('background:start') - await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true })) + await new Promise((resolve) => + signal.addEventListener('abort', resolve, { once: true }) + ) order.push('background:aborted') throw new DOMException('Recognition cancelled', 'AbortError') }, @@ -201,4 +209,155 @@ describe('transcript repository', () => { }) repository.close() }) + + it('merges legacy transcripts idempotently without overwriting current records', () => { + const legacyPath = join(root, 'legacy-transcripts.sqlite') + const currentPath = join(root, 'current-transcripts.sqlite') + const legacy = new SqliteTranscriptRepository(legacyPath) + const current = new SqliteTranscriptRepository(currentPath) + const base: TranscriptRecord = { + accountId: 'account-a', + messageIdentity: 'message-1', + audioHash: 'audio-1', + processorVersion: 'processor-v1', + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a', + transcript: '旧缓存文字', + durationMs: 1200, + createdAt: 1, + updatedAt: 1 + } + legacy.save(base) + legacy.save({ ...base, messageIdentity: 'message-2', audioHash: 'audio-2' }) + current.save({ ...base, transcript: '当前缓存文字', updatedAt: 2 }) + legacy.close() + + expect(current.mergeFrom(legacyPath)).toBe(1) + expect(current.mergeFrom(legacyPath)).toBe(0) + expect( + current.find({ + accountId: base.accountId, + messageIdentity: base.messageIdentity, + audioHash: base.audioHash, + processorVersion: base.processorVersion, + recognizerId: base.recognizerId, + modelVersion: base.modelVersion, + modelFingerprint: base.modelFingerprint + })?.transcript + ).toBe('当前缓存文字') + expect(current.findLatest('account-a', 'message-2')?.transcript).toBe('旧缓存文字') + current.close() + }) +}) + +describe('voice pipeline cache lookup', () => { + it('returns a compatible message cache before reading or decoding audio', async () => { + const repository = new SqliteTranscriptRepository(join(root, 'fast-cache.sqlite')) + const reference = { sessionId: 'session', localId: 1, createTime: 2 } + repository.save({ + accountId: 'account-a', + messageIdentity: voiceMessageIdentity(reference), + audioHash: 'audio-1', + processorVersion: 'processor-v1', + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a', + transcript: '快速命中', + durationMs: 900, + createdAt: 1, + updatedAt: 1 + }) + const resolve = vi.fn() + const decode = vi.fn() + const process = vi.fn() + const recognize = vi.fn() + const pipeline = new VoicePipeline( + { resolve }, + { decode } as never, + { version: 'processor-v1', process }, + { + metadata: { + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a' + }, + recognize, + dispose: vi.fn() + }, + repository + ) + + await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({ + transcript: '快速命中', + cached: true + }) + expect(resolve).not.toHaveBeenCalled() + expect(decode).not.toHaveBeenCalled() + expect(process).not.toHaveBeenCalled() + expect(recognize).not.toHaveBeenCalled() + repository.close() + }) + + it('falls through when the cached processor version is incompatible', async () => { + const repository = new SqliteTranscriptRepository(join(root, 'version-cache.sqlite')) + const reference = { sessionId: 'session', localId: 1, createTime: 2 } + repository.save({ + accountId: 'account-a', + messageIdentity: voiceMessageIdentity(reference), + audioHash: 'old-audio', + processorVersion: 'processor-v0', + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a', + transcript: '不兼容缓存', + durationMs: 900, + createdAt: 1, + updatedAt: 1 + }) + const resolve = vi.fn().mockResolvedValue({ + data: Buffer.from('encoded'), + codec: 'silk', + sourceHash: 'new-audio' + }) + const pipeline = new VoicePipeline( + { resolve }, + { + decode: vi.fn().mockResolvedValue({ + pcm: Buffer.from([1, 0]), + sampleRate: 16000, + channels: 1, + sourceHash: 'new-audio' + }) + } as never, + { + version: 'processor-v1', + process: vi.fn().mockReturnValue({ + samples: new Float32Array([0.1]), + sampleRate: 16000, + channels: 1, + sourceHash: 'new-audio', + processorVersion: 'processor-v1', + durationMs: 1 + }) + }, + { + metadata: { + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a' + }, + recognize: vi.fn().mockResolvedValue({ text: '新转写' }), + dispose: vi.fn() + }, + repository + ) + + await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({ + transcript: '新转写', + cached: false + }) + expect(resolve).toHaveBeenCalledOnce() + repository.close() + }) }) diff --git a/tests/unit/voice-recognition-use-case.test.ts b/tests/unit/voice-recognition-use-case.test.ts index 48698ba..0e21e1d 100644 --- a/tests/unit/voice-recognition-use-case.test.ts +++ b/tests/unit/voice-recognition-use-case.test.ts @@ -72,7 +72,9 @@ describe('VoiceRecognitionUseCase transcript updates', () => { it('does not publish a transcript after the account generation changes mid-recognition', async () => { const useCase = createUseCase() - let finish: ((value: { transcript: string; durationMs: number; cached: boolean }) => void) | undefined + let finish: + | ((value: { transcript: string; durationMs: number; cached: boolean }) => void) + | undefined const state = useCase as unknown as { accountGeneration: number pipeline: { run: ReturnType } @@ -98,4 +100,24 @@ describe('VoiceRecognitionUseCase transcript updates', () => { expect(listener).not.toHaveBeenCalled() await useCase.dispose() }) + + it('publishes an explicit cached transcript for a coalesced export index refresh', async () => { + const useCase = createUseCase() + const listener = vi.fn().mockResolvedValue(undefined) + useCase.onTranscriptUpdate(listener) + const reference = { sessionId: 'fixture-contact', localId: 11, createTime: 1_785_895_202 } + + await useCase.publishTranscript(reference, '缓存导出文字', true) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + accountIdentity: 'account-a', + reference, + state: 'transcribed', + transcript: '缓存导出文字', + cached: true + }) + ) + await useCase.dispose() + }) }) diff --git a/tests/unit/voice-service.test.ts b/tests/unit/voice-service.test.ts new file mode 100644 index 0000000..5e4bb53 --- /dev/null +++ b/tests/unit/voice-service.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { VoiceService } from '../../src/main/voice-service' + +describe('VoiceService batch lookup', () => { + it('retries failed batch entries with the compatible single-item lookup', async () => { + const getVoiceDataBatch = vi.fn().mockResolvedValue([ + { success: false, error: '获取语音数据失败' }, + { success: false, error: '获取语音数据失败' } + ]) + const service = new VoiceService({ getVoiceDataBatch } as never) + const resolveVoice = vi + .spyOn(service, 'resolveVoice') + .mockImplementation(async (_sessionId, localId) => ({ + success: true, + data: `voice-${localId}` + })) + + const result = await service.resolveVoices([ + { sessionId: 'session', localId: 10, createTime: 100, svrId: '1000' }, + { sessionId: 'session', localId: 11, createTime: 101, svrId: '1001' } + ]) + + expect(result).toEqual([ + { success: true, data: 'voice-10' }, + { success: true, data: 'voice-11' } + ]) + expect(resolveVoice).toHaveBeenCalledTimes(2) + expect(resolveVoice).toHaveBeenNthCalledWith(1, 'session', 10, 100, '1000') + expect(resolveVoice).toHaveBeenNthCalledWith(2, 'session', 11, 101, '1001') + }) +}) diff --git a/tests/unit/wcdb-close.test.ts b/tests/unit/wcdb-close.test.ts index c5364ae..a7e0bb1 100644 --- a/tests/unit/wcdb-close.test.ts +++ b/tests/unit/wcdb-close.test.ts @@ -37,4 +37,59 @@ describe('Wcdb4Client shutdown', () => { expect(shutdown).toHaveBeenCalledOnce() } }) + + it('restores async batch voice results to request order', async () => { + const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client + setPrivate(client, 'wcdbGetVoiceDataBatch', vi.fn()) + setPrivate( + client, + 'callJsonAsync', + vi.fn().mockResolvedValue([ + { index: 1, success: false, error: 'missing' }, + { index: 0, Success: true, hex: 'aabb' } + ]) + ) + + await expect( + client.getVoiceDataBatch([ + { sessionId: 'a', createTime: 1, localId: 10, candidates: ['a'] }, + { sessionId: 'b', createTime: 2, localId: 20, candidates: ['b'] } + ]) + ).resolves.toEqual([ + { success: true, hex: 'aabb', error: '' }, + { success: false, hex: undefined, error: 'missing' } + ]) + }) + + it('uses the async Koffi path for a single voice lookup and releases the result', async () => { + const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client + const nativePointer = { value: 'aabb' } + const freeString = vi.fn() + const nativeFunction = { + async: vi.fn((...args: unknown[]) => { + const outHex = args.at(-2) as [unknown] + const callback = args.at(-1) as (error: unknown, code: number) => void + outHex[0] = nativePointer + queueMicrotask(() => callback(null, 0)) + }) + } + setPrivate(client, 'wcdbGetVoiceData', nativeFunction) + setPrivate(client, 'wcdbFreeString', freeString) + setPrivate(client, 'handle', 1) + setPrivate(client, 'closing', false) + setPrivate(client, 'nativeCallsInFlight', new Set()) + setPrivate( + client, + 'decodeHexPtr', + vi.fn(() => 'aabb') + ) + + await expect(client.getVoiceData('session', 100, ['session'], 10, 20)).resolves.toEqual({ + success: true, + hex: 'aabb', + error: '' + }) + expect(nativeFunction.async).toHaveBeenCalledOnce() + expect(freeString).toHaveBeenCalledWith(nativePointer) + }) })