perf: 加速语音转写缓存命中

迁移旧版语音转写缓存,并将补迁失败降级为一次性失败状态。

按账号和消息标识优先命中兼容缓存,未命中时才读取音频并计算哈希;导出缓存命中后合并异步刷新知识索引。

补充迁移、缓存快速路径、批量语音读取和导出流程测试。
This commit is contained in:
Nanin
2026-08-12 20:01:40 +08:00
parent a80624d6ab
commit 34b86af0be
15 changed files with 1026 additions and 165 deletions
+163 -4
View File
@@ -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()
})
})