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