mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
perf: 加速语音转写缓存命中
迁移旧版语音转写缓存,并将补迁失败降级为一次性失败状态。 按账号和消息标识优先命中兼容缓存,未命中时才读取音频并计算哈希;导出缓存命中后合并异步刷新知识索引。 补充迁移、缓存快速路径、批量语音读取和导出流程测试。
This commit is contained in:
@@ -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<void>((resolve) => signal.addEventListener('abort', resolve, { once: true }))
|
||||
await new Promise<void>((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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user