diff --git a/resources/wcdb/macos/arm64/libWCDB.dylib b/resources/wcdb/macos/arm64/libWCDB.dylib index 18f8509..71060b9 100755 Binary files a/resources/wcdb/macos/arm64/libWCDB.dylib and b/resources/wcdb/macos/arm64/libWCDB.dylib differ diff --git a/resources/wcdb/macos/arm64/libwcdb_api.dylib b/resources/wcdb/macos/arm64/libwcdb_api.dylib index 19ca27d..3bab03e 100755 Binary files a/resources/wcdb/macos/arm64/libwcdb_api.dylib and b/resources/wcdb/macos/arm64/libwcdb_api.dylib differ diff --git a/resources/wcdb/macos/x64/libWCDB.dylib b/resources/wcdb/macos/x64/libWCDB.dylib index 2475a7a..948c568 100755 Binary files a/resources/wcdb/macos/x64/libWCDB.dylib and b/resources/wcdb/macos/x64/libWCDB.dylib differ diff --git a/resources/wcdb/macos/x64/libwcdb_api.dylib b/resources/wcdb/macos/x64/libwcdb_api.dylib index f621628..d076466 100755 Binary files a/resources/wcdb/macos/x64/libwcdb_api.dylib and b/resources/wcdb/macos/x64/libwcdb_api.dylib differ diff --git a/src/main/export-service.ts b/src/main/export-service.ts index f339d29..08b8801 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -1246,10 +1246,15 @@ async function runSingleExport( const wavChannels = audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1 const pcmBytes = Math.max(0, audioBuffer.length - 44) - message.voiceDuration = Math.max( - 1, - Math.round(pcmBytes / (wavSampleRate * wavChannels * 2)) - ) + // 口径统一:消息解析阶段已从 拿到微信的原始秒数(带小数), + // 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算—— + // 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。 + if (message.voiceDuration == null) { + message.voiceDuration = Math.max( + 1, + Math.round(pcmBytes / (wavSampleRate * wavChannels * 2)) + ) + } } catch (error) { keepMediaError( request, diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index f9e3627..08be1a6 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -588,7 +588,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `
${renderAvatar(item.sender, 'ranking')} ${index + 1}. ${escapeHtml(item.sender)} - ${item.count} 条 · ${item.durationSec} 秒 + ${item.count} 条 · ${Math.round(item.durationSec)} 秒
` ) .join('') diff --git a/src/main/index.ts b/src/main/index.ts index 9464d55..a493a53 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1853,12 +1853,15 @@ app.whenReady().then(async () => { return error ? { success: false, error } : { success: true } }) - ipcMain.handle('voice:recognize', (_, reference: VoiceMessageReference) => { - if (!voiceRecognition) { - return { success: false, code: 'NOT_CONNECTED', error: '语音识别服务尚未初始化' } + ipcMain.handle( + 'voice:recognize', + (_, reference: VoiceMessageReference, options?: { force?: boolean }) => { + if (!voiceRecognition) { + return { success: false, code: 'NOT_CONNECTED', error: '语音识别服务尚未初始化' } + } + return voiceRecognition.recognize(reference, options) } - return voiceRecognition.recognize(reference) - }) + ) ipcMain.handle('voice:getTranscriptSnapshot', (_, reference: VoiceMessageReference) => { return voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' as const } diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index c12d3d4..4be0c35 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -125,7 +125,10 @@ export type ParsedContent = export function parseMessageContent(content: string, messageType: number): ParsedContent { // Voice rows may keep their binary payload outside msgContent, so an empty // content string is still a valid voice message. - if (messageType === 34) return { type: 'voice' } + if (messageType === 34) { + const duration = parseVoiceDurationSeconds(content) + return duration === undefined ? { type: 'voice' } : { type: 'voice', duration } + } if (!content || typeof content !== 'string') { return { type: 'unknown', raw: content || '' } } @@ -157,6 +160,33 @@ export function parseMessageContent(content: string, messageType: number): Parse } } +/** + * 语音时长藏在解压后的 message_content 里:``,单位毫秒。 + * Msg_* 表没有 voice_length 列,这是唯一来源。 + * + * `` 上两个极易混淆的属性(真机实测,同一条 1.6 秒语音,2026-09-20): + * + * - `voicelength="1600"` → **毫秒时长**。这条语音微信气泡显示 2"(1.6 秒四舍五入)。 + * **要取的是它。** + * - `length="6672"` → **SILK 编码数据的字节数,与时长无关**。 + * 已验证:`wcdb_get_voice_data` 取出的 SILK 恰好是 6672 字节, + * 解码后为 51200 字节 PCM(1.6 秒)。误取它会算出 6.672 秒,把 2" 显示成 0:07。 + * + * 换算成秒后**刻意保留小数**(1600ms → 1.6):在这里取整会把精度永久丢掉, + * 后面显示层再怎么四舍五入都对不回微信的口径(微信是四舍五入到整秒)。 + * + * 注:`` 的 `length` 同理是字节数,不是时长。 + */ +function parseVoiceDurationSeconds(content: string): number | undefined { + if (!content || typeof content !== 'string') return undefined + const decoded = decodeXmlEntities(stripChatroomPrefix(content)) + const rawLength = extractXmlAttribute(decoded, 'voicemsg', 'voicelength') + if (!rawLength) return undefined + const milliseconds = Number(rawLength) + if (!Number.isFinite(milliseconds) || milliseconds <= 0) return undefined + return milliseconds / 1000 +} + function parseVideoMessage(content: string): ParsedContent { const decoded = decodeXmlEntities(stripChatroomPrefix(content)) const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5')) diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 5fa81a5..95fb5dc 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -669,6 +669,9 @@ function listSourceMessages( : msg.mesLocalID || Math.random().toString() ) const imageContent = contentData?.type === 'image' ? contentData : undefined + // 语音时长来自 message_content 的 (毫秒)——注意不是 length, + // 那是 SILK 数据字节数。已在 parseMessageContent 里换算成秒。 + const voiceDuration = contentData?.type === 'voice' ? contentData.duration : undefined // Local ids repeat across conversations. Scope media handles to this database // connection and image without changing the message id used by other clients. const mediaId = imageContent @@ -737,7 +740,8 @@ function listSourceMessages( createTime, recoveredFromRecallJournal, contentData, - media + media, + voiceDuration } }) diff --git a/src/main/voice-pipeline/voice-pipeline.ts b/src/main/voice-pipeline/voice-pipeline.ts index e7f13fc..da46c21 100644 --- a/src/main/voice-pipeline/voice-pipeline.ts +++ b/src/main/voice-pipeline/voice-pipeline.ts @@ -37,21 +37,29 @@ export class VoicePipeline { async run( accountId: string, reference: VoiceMessageReference, - signal?: AbortSignal + signal?: AbortSignal, + options?: { force?: boolean } ): 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 + /* + * findCompatible 只按消息身份匹配,不含 audio_hash:音频被换掉(例如取音频的 + * 逻辑修好后)时它仍会命中旧记录。用户主动触发的识别必须跳过它,重新取一次 + * 音频 —— 音频级缓存 find(key) 带 audio_hash,才是正确的失效机制。 + */ + if (!options?.force) { + 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 + } } } diff --git a/src/main/voice-pipeline/voice-recognition-use-case.ts b/src/main/voice-pipeline/voice-recognition-use-case.ts index c728329..6e2faba 100644 --- a/src/main/voice-pipeline/voice-recognition-use-case.ts +++ b/src/main/voice-pipeline/voice-recognition-use-case.ts @@ -23,6 +23,7 @@ type TranscriptUpdateListener = (update: VoiceTranscriptUpdate) => Promise type RecognitionOptions = { priority?: VoiceRecognitionPriority publishTranscriptUpdate?: boolean + force?: boolean } export class VoiceRecognitionUseCase { @@ -108,7 +109,9 @@ export class VoiceRecognitionUseCase { error: '请先下载语音识别模型' } as const } - const result = await pipeline.run(accountId, reference, signal) + const result = await pipeline.run(accountId, reference, signal, { + force: options?.force + }) if (signal.aborted || !this.isCurrentAccount(accountId, generation)) { throw new DOMException('Recognition cancelled', 'AbortError') } diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index 32ba639..6ba99ba 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -73,11 +73,19 @@ export class VoiceService { pcmResult.audio.sampleRate, pcmResult.audio.channels ) + // duration 由 PCM 字节数反算(wavData 去掉 44 字节头即 PCM), + // 用来和用户实际听到的长度对齐、排查「时长显示不对」这类问题。 + // 注意它**不是权威值**:权威时长在消息 XML 的 (毫秒), + // 显示层以那个为准;这里只是解码结果的自证。 + const durationSeconds = + pcmData.length / (pcmResult.audio.sampleRate * pcmResult.audio.channels * 2) console.log( '[VoiceService] wavData length:', wavData.length, 'base64 length:', - wavData.toString('base64').length + wavData.toString('base64').length, + 'duration:', + `${durationSeconds.toFixed(2)}s` ) const base64Data = wavData.toString('base64') diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e9acd83..8d6c545 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -378,7 +378,10 @@ declare global { cancelVoiceModelDownload: () => Promise<{ success: boolean }> removeVoiceModel: () => Promise openVoiceModelDirectory: () => Promise<{ success: boolean; error?: string }> - recognizeVoice: (reference: VoiceMessageReference) => Promise + recognizeVoice: ( + reference: VoiceMessageReference, + options?: { force?: boolean } + ) => Promise getVoiceTranscriptSnapshot: ( reference: VoiceMessageReference ) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0b7e73a..f4f3c92 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -285,8 +285,10 @@ const api = { removeVoiceModel: (): Promise => ipcRenderer.invoke('voice:removeModel'), openVoiceModelDirectory: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('voice:openModelDirectory'), - recognizeVoice: (reference: VoiceMessageReference): Promise => - ipcRenderer.invoke('voice:recognize', reference), + recognizeVoice: ( + reference: VoiceMessageReference, + options?: { force?: boolean } + ): Promise => ipcRenderer.invoke('voice:recognize', reference, options), getVoiceTranscriptSnapshot: ( reference: VoiceMessageReference ): Promise => diff --git a/src/renderer/src/components/VoicePlayer.tsx b/src/renderer/src/components/VoicePlayer.tsx index 385a63a..ce065e3 100644 --- a/src/renderer/src/components/VoicePlayer.tsx +++ b/src/renderer/src/components/VoicePlayer.tsx @@ -73,12 +73,14 @@ export function VoicePlayer({ const audio = new Audio() audio.preload = 'auto' audio.src = blobUrl - audio.onloadedmetadata = () => { - if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration) - } - audio.ontimeupdate = () => { + // Silk 解码出来的时长比微信的 length 系统性偏短(实测少 20–279ms), + // 用它覆盖会把刚补回来的精度又弄丢:有原生时长时一律不覆盖,没有才回退。 + const useDecodedDuration = () => { + if (duration !== undefined) return if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration) } + audio.onloadedmetadata = useDecodedDuration + audio.ontimeupdate = useDecodedDuration audio.onended = () => { setIsPlaying(false) if (globalCurrentAudio === audio) { @@ -89,7 +91,7 @@ export function VoicePlayer({ audioRef.current = audio objectUrlRef.current = blobUrl return audio - }, []) + }, [duration]) const handlePlayPause = useCallback(async () => { if (loading) return @@ -163,7 +165,8 @@ export function VoicePlayer({ setTranscribing(true) try { - const result = await window.api.recognizeVoice(voiceReference) + // 用户主动触发:跳过身份级缓存,重新取音频(音频级缓存仍生效)。 + const result = await window.api.recognizeVoice(voiceReference, { force: true }) if (result.success) { setTranscript(result.transcript?.trim() || '未识别出文字') setModelStatus(null) @@ -204,8 +207,11 @@ export function VoicePlayer({ const formatDuration = (seconds: number | undefined): string => { if (!seconds || !isFinite(seconds)) return '0:00' - const mins = Math.floor(seconds / 60) - const secs = Math.floor(seconds % 60) + // 微信的口径是四舍五入到整秒(length=4211ms 显示 4"),所以这里也必须 round 而非 floor。 + // 先整体取整再拆分钟/秒,59.6 → "1:00" 而不会冒出 "0:60"。 + const rounded = Math.round(seconds) + const mins = Math.floor(rounded / 60) + const secs = rounded % 60 return `${mins}:${secs.toString().padStart(2, '0')}` } diff --git a/src/renderer/src/utils/group-report-facts.ts b/src/renderer/src/utils/group-report-facts.ts index efad33d..6bcae05 100644 --- a/src/renderer/src/utils/group-report-facts.ts +++ b/src/renderer/src/utils/group-report-facts.ts @@ -138,9 +138,13 @@ export const summarySender = ( export const summaryContent = (message: Message): string => { const data = message.contentData if (message.type === '语音' || data?.type === 'voice') { + // 语音时长来自 (毫秒),是带小数的秒(1600ms → 1.6): + // 累加时保留精度,只在显示给人的文案里取整。 + const durationSec = data?.type === 'voice' ? data.duration : undefined + const durationLabel = durationSec ? ` ${Math.round(durationSec)}秒` : '' return message.voiceTranscript?.trim() - ? `[语音${data?.type === 'voice' && data.duration ? ` ${data.duration}秒` : ''}] ${message.voiceTranscript.trim()}` - : `[语音${data?.type === 'voice' && data.duration ? ` ${data.duration}秒` : ''}]` + ? `[语音${durationLabel}] ${message.voiceTranscript.trim()}` + : `[语音${durationLabel}]` } if (!data) return message.content?.trim() || `[${message.type || '消息'}]` @@ -567,14 +571,14 @@ const buildMediaSection = async ( voiceHighlights.push({ title: '语音输出王', sender: voiceLeaderboard[0].sender, - note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。` + note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${Math.round(voiceLeaderboard[0].durationSec)} 秒。` }) } if (bestStreak && bestStreak.count >= 2) { voiceHighlights.push({ title: '连续发言时刻', sender: bestStreak.sender, - note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。` + note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${Math.round(bestStreak.duration)} 秒。` }) } @@ -810,7 +814,7 @@ export const buildGroupReportFacts = async ( const factsPrompt = [ `报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`, - `消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`, + `消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${Math.round(voiceDurationSec)} 秒)。`, activeTimeline ? `活跃时段:${activeTimeline}` : '', // AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision) (media.visionGallery?.length ?? 0) > 0 @@ -824,7 +828,7 @@ export const buildGroupReportFacts = async ( voiceLeaderboard.length ? `语音榜:${voiceLeaderboard .slice(0, 3) - .map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec} 秒`) + .map((item) => `${item.sender} ${item.count} 条 / ${Math.round(item.durationSec)} 秒`) .join(';')}` : '', collectQuestionCandidates(messages, contact, isGroup).length diff --git a/tests/component/voice-player.test.tsx b/tests/component/voice-player.test.tsx index 3db4270..9c8a589 100644 --- a/tests/component/voice-player.test.tsx +++ b/tests/component/voice-player.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { VoicePlayer } from '../../src/renderer/src/components/VoicePlayer' @@ -6,6 +6,8 @@ import { VoicePlayer } from '../../src/renderer/src/components/VoicePlayer' const play = vi.fn(() => Promise.resolve()) const pause = vi.fn() +let lastAudio: FakeAudio | null = null + class FakeAudio { preload = '' src = '' @@ -18,12 +20,27 @@ class FakeAudio { pause = pause load = vi.fn() removeAttribute = vi.fn() + + constructor() { + lastAudio = this + } } +const renderPlayer = (duration?: number) => + render( + + ) + describe('VoicePlayer', () => { beforeEach(() => { play.mockClear() pause.mockClear() + lastAudio = null vi.stubGlobal('Audio', FakeAudio) window.api = { getVoiceData: vi.fn().mockResolvedValue({ @@ -70,17 +87,73 @@ describe('VoicePlayer', () => { render() await userEvent.click(screen.getByRole('button', { name: '转文字' })) + // 用户主动触发必须带 force:否则会被身份级缓存(不含 audio_hash)短路。 await waitFor(() => - expect(window.api.recognizeVoice).toHaveBeenCalledWith({ - sessionId: 'filehelper', - localId: 11, - createTime: 1785553200, - svrId: undefined - }) + expect(window.api.recognizeVoice).toHaveBeenCalledWith( + { + sessionId: 'filehelper', + localId: 11, + createTime: 1785553200, + svrId: undefined + }, + { force: true } + ) ) expect(await screen.findByText('这是固定的测试转写')).toBeInTheDocument() }) + it('rounds the shown duration the way WeChat does', () => { + // 微信 4211ms 显示 4",所以口径是 round 不是 floor;进位要传给分钟位。 + const cases: Array<[number | undefined, string]> = [ + [1.7, '0:02'], + [4.211, '0:04'], + [7.505, '0:08'], + [59.6, '1:00'], + [119.6, '2:00'], + [3, '0:03'], + [7, '0:07'], + [15, '0:15'], + [0, '0:00'], + [undefined, '0:00'], + [NaN, '0:00'] + ] + for (const [duration, expected] of cases) { + const { container, unmount } = renderPlayer(duration) + expect(container.querySelector('.voice-duration')?.textContent).toBe(expected) + unmount() + } + }) + + it('keeps the WeChat duration instead of the systematically short decoded one', async () => { + const { container } = renderPlayer(1.979) + expect(container.querySelector('.voice-duration')?.textContent).toBe('0:02') + + await userEvent.click(container.querySelector('.voice-message') as HTMLElement) + await waitFor(() => expect(lastAudio).not.toBeNull()) + // Silk 解码时长比微信 length 系统性偏短(实测少 20–279ms),用它覆盖会把精度弄丢。 + lastAudio!.duration = 1 + await act(async () => { + lastAudio!.onloadedmetadata?.() + lastAudio!.ontimeupdate?.() + }) + + expect(container.querySelector('.voice-duration')?.textContent).toBe('0:02') + }) + + it('falls back to the decoded duration when WeChat did not provide one', async () => { + const { container } = renderPlayer() + expect(container.querySelector('.voice-duration')?.textContent).toBe('0:00') + + await userEvent.click(container.querySelector('.voice-message') as HTMLElement) + await waitFor(() => expect(lastAudio).not.toBeNull()) + lastAudio!.duration = 59.6 + lastAudio!.onloadedmetadata?.() + + await waitFor(() => + expect(container.querySelector('.voice-duration')?.textContent).toBe('1:00') + ) + }) + it('opens centralized settings when recognition assets are missing', async () => { vi.mocked(window.api.getVoiceModelStatus).mockResolvedValue({ modelId: 'sensevoice-small-int8', diff --git a/tests/unit/group-report.test.ts b/tests/unit/group-report.test.ts index c8807a7..917cda0 100644 --- a/tests/unit/group-report.test.ts +++ b/tests/unit/group-report.test.ts @@ -4,7 +4,10 @@ import { getSummaryDateRangeAt, parseGroupDailyReport } from '../../src/renderer/src/utils/group-report' -import { summaryContent } from '../../src/renderer/src/utils/group-report-facts' +import { + buildGroupReportFacts, + summaryContent +} from '../../src/renderer/src/utils/group-report-facts' import { summarySender } from '../../src/renderer/src/utils/group-report-facts' import { selectHeroParticipantNames } from '../../src/shared/group-report' import type { GroupReportMetadata } from '../../src/shared/group-report' @@ -62,6 +65,37 @@ describe('group report parsing', () => { expect(summaryContent(message)).toContain('今晚八点确认发布。') }) + it('rounds accumulated voice seconds in the facts summary', async () => { + // 语音时长是 (毫秒)换算来的小数秒(1600ms → 1.6)。 + // 累加必须保留原始精度(否则多条累积会越差越多),但展示给用户的文案要取整。 + const messages: Message[] = [ + { + id: 'voice-a', + from: 'member', + type: '语音', + datetime: '2026-08-06 10:00:00', + content: '[语音]', + isSender: false, + contentData: { type: 'voice', duration: 1.979 } + }, + { + id: 'voice-b', + from: 'member', + type: '语音', + datetime: '2026-08-06 10:00:05', + content: '[语音]', + isSender: false, + contentData: { type: 'voice', duration: 5.379 } + } + ] + + const snapshot = await buildGroupReportFacts(messages, null, true, 'compact') + + // 1.979 + 5.379 = 7.358 → 文案必须显示整数 + expect(snapshot.factsPrompt).toContain('累计 7 秒') + expect(snapshot.factsPrompt).not.toContain('7.358') + }) + it('includes a voice transcript when legacy cached messages have no contentData', () => { const message: Message = { id: 'voice-legacy', diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts index 8e486bb..7a072f6 100644 --- a/tests/unit/message-parser.test.ts +++ b/tests/unit/message-parser.test.ts @@ -17,6 +17,38 @@ describe('message parser', () => { ).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' }) }) + it('reads the WeChat voice length in milliseconds and keeps fractional seconds', () => { + // 真机实测(2026-09-20):voicelength 才是毫秒时长,length 是编码数据长度——别取错。 + // 属性值取自一条真机采样的语音(1.6 秒,微信气泡显示 2")。 + const parsed = parseMessageContent( + '', + 34 + ) + expect(parsed).toEqual({ type: 'voice', duration: 1.6 }) + // 误取 length 会得到 6.672 秒(把 2" 的语音显示成 0:07)——这条断言就是防这个回归。 + expect(parsed).not.toEqual({ type: 'voice', duration: 6.672 }) + + // 微信四舍五入到整秒,取整必须在显示层做,不能在解析层丢精度。 + expect(parseMessageContent('', 34)).toEqual({ + type: 'voice', + duration: 4.211 + }) + }) + + it('leaves the voice duration undefined when the payload is missing or unusable', () => { + expect(parseMessageContent('', 34)).toEqual({ type: 'voice' }) + expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' }) + expect(parseMessageContent('', 34)).toEqual({ + type: 'voice' + }) + expect(parseMessageContent('', 34)).toEqual({ + type: 'voice' + }) + expect(parseMessageContent('', 34)).toEqual({ + type: 'voice' + }) + }) + it('keeps video metadata when WeChat omits every MD5 field', () => { const parsed = parseMessageContent( '', diff --git a/tests/unit/voice-pipeline.test.ts b/tests/unit/voice-pipeline.test.ts index 22d22f2..89e7398 100644 --- a/tests/unit/voice-pipeline.test.ts +++ b/tests/unit/voice-pipeline.test.ts @@ -361,3 +361,128 @@ describe('voice pipeline cache lookup', () => { repository.close() }) }) + +/* + * force 的语义:用户主动触发时必须绕开 findCompatible(它只按消息身份匹配, + * 不含 audio_hash),重新取一次音频;音频级 find(key) 带 audio_hash,保留。 + */ +describe('voice pipeline force recognition', () => { + const forceRoot = mkdtempSync(join(tmpdir(), 'wxe-voice-pipeline-force-')) + afterAll(() => rmSync(forceRoot, { recursive: true, force: true })) + + function createPipeline(repository: SqliteTranscriptRepository, transcript: string) { + const resolve = vi.fn().mockResolvedValue({ + data: Buffer.from('encoded'), + codec: 'silk', + sourceHash: 'fresh-audio' + }) + const decode = vi.fn().mockResolvedValue({ + pcm: Buffer.from([1, 0]), + sampleRate: 16000, + channels: 1, + sourceHash: 'fresh-audio' + }) + const process = vi.fn().mockReturnValue({ + samples: new Float32Array([0.1]), + sampleRate: 16000, + channels: 1, + sourceHash: 'fresh-audio', + processorVersion: 'processor-v1', + durationMs: 1 + }) + const recognize = vi.fn().mockResolvedValue({ text: transcript }) + 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 + ) + return { pipeline, resolve, decode, process, recognize } + } + + function seedStaleTranscript(repository: SqliteTranscriptRepository) { + repository.save({ + accountId: 'account-a', + messageIdentity: voiceMessageIdentity(reference), + audioHash: 'stale-audio', + processorVersion: 'processor-v1', + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a', + transcript: '旧音频的转写', + durationMs: 900, + createdAt: 1, + updatedAt: 1 + }) + } + + const reference = { sessionId: 'session', localId: 1, createTime: 2 } + + it('skips the identity-level cache when force is requested', async () => { + const repository = new SqliteTranscriptRepository(join(forceRoot, 'forced.sqlite')) + seedStaleTranscript(repository) + const findCompatible = vi.spyOn(repository, 'findCompatible') + const find = vi.spyOn(repository, 'find') + const { pipeline, resolve, recognize } = createPipeline(repository, '正确音频的转写') + + await expect( + pipeline.run('account-a', reference, undefined, { force: true }) + ).resolves.toMatchObject({ transcript: '正确音频的转写', cached: false }) + + expect(findCompatible).not.toHaveBeenCalled() + expect(resolve).toHaveBeenCalledOnce() + expect(find).toHaveBeenCalled() + expect(recognize).toHaveBeenCalledOnce() + expect( + repository.find({ + accountId: 'account-a', + messageIdentity: voiceMessageIdentity(reference), + audioHash: 'fresh-audio', + processorVersion: 'processor-v1', + recognizerId: 'sensevoice', + modelVersion: 'model-v1', + modelFingerprint: 'fingerprint-a' + })?.transcript + ).toBe('正确音频的转写') + repository.close() + }) + + it('keeps consulting the identity-level cache without force', async () => { + const repository = new SqliteTranscriptRepository(join(forceRoot, 'unchanged.sqlite')) + seedStaleTranscript(repository) + const findCompatible = vi.spyOn(repository, 'findCompatible') + const { pipeline, resolve, decode, process, recognize } = createPipeline( + repository, + '新转写' + ) + + await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({ + transcript: '旧音频的转写', + cached: true + }) + expect(findCompatible).toHaveBeenCalledOnce() + expect(resolve).not.toHaveBeenCalled() + expect(decode).not.toHaveBeenCalled() + expect(process).not.toHaveBeenCalled() + expect(recognize).not.toHaveBeenCalled() + repository.close() + + const explicitFalse = new SqliteTranscriptRepository(join(forceRoot, 'explicit-false.sqlite')) + seedStaleTranscript(explicitFalse) + const second = createPipeline(explicitFalse, '新转写') + await expect( + second.pipeline.run('account-a', reference, undefined, { force: false }) + ).resolves.toMatchObject({ transcript: '旧音频的转写', cached: true }) + expect(second.resolve).not.toHaveBeenCalled() + explicitFalse.close() + }) +}) diff --git a/tests/unit/voice-recognition-use-case.test.ts b/tests/unit/voice-recognition-use-case.test.ts index 0e21e1d..d127dde 100644 --- a/tests/unit/voice-recognition-use-case.test.ts +++ b/tests/unit/voice-recognition-use-case.test.ts @@ -101,6 +101,49 @@ describe('VoiceRecognitionUseCase transcript updates', () => { await useCase.dispose() }) + it('forwards force to the pipeline so manual re-recognition can bypass the identity cache', async () => { + const useCase = createUseCase() + const state = useCase as unknown as { + pipeline: { run: ReturnType } + } + state.pipeline.run.mockResolvedValue({ + transcript: '重新识别的文字', + durationMs: 900, + cached: false + }) + const reference = { sessionId: 'fixture-contact', localId: 12, createTime: 1_785_895_203 } + + const result = await useCase.recognize(reference, { force: true }) + + expect(result).toMatchObject({ success: true, transcript: '重新识别的文字' }) + expect(state.pipeline.run).toHaveBeenCalledWith( + 'account-a', + reference, + expect.anything(), + { force: true } + ) + await useCase.dispose() + }) + + it('leaves force undefined when the caller does not ask for a recompute', async () => { + const useCase = createUseCase() + const state = useCase as unknown as { + pipeline: { run: ReturnType } + } + state.pipeline.run.mockResolvedValue({ transcript: '缓存文字', durationMs: 900, cached: true }) + const reference = { sessionId: 'fixture-contact', localId: 13, createTime: 1_785_895_204 } + + await useCase.recognize(reference) + + expect(state.pipeline.run).toHaveBeenCalledWith( + 'account-a', + reference, + expect.anything(), + { force: undefined } + ) + 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)