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
+59 -4
View File
@@ -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<MigrationItemStatus> {
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<MigrationFlowResult> {
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<Mig
type: execution.state.status === 'completed' ? ('info' as const) : ('warning' as const),
title: 'TraceMemo 数据迁移',
message:
execution.state.status === 'completed'
? 'WechatExplorer 数据迁移完成'
: '部分数据未能迁移',
execution.state.status === 'completed' ? 'WechatExplorer 数据迁移完成' : '部分数据未能迁移',
detail:
execution.state.status === 'completed'
? '核心用户资产已复制到 TraceMemo。旧目录仍完整保留。'
+2 -1
View File
@@ -131,7 +131,8 @@ export function hasValidUserAssets(root: string): boolean {
'wechat-db-key.bin',
'wechat-image-keys.bin',
'image-insights.json',
'wechat-share-service.bin'
'wechat-share-service.bin',
path.join('cache', 'voice-transcripts.sqlite')
]
if (markers.some((marker) => isNonEmptyFile(path.join(root, marker)))) return true
if (hasKnowledgeDatabase(root)) return true
+153 -66
View File
@@ -754,7 +754,8 @@ const preserveLegacyCombinedArchive = async (outputDir: string): Promise<void> =
async function runSingleExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>,
options: SingleExportOptions = {}
): Promise<ExportResult> {
const manageJob = options.manageJob !== false
@@ -1083,89 +1084,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<void>((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 +1585,8 @@ async function runSingleExport(
async function runAllExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
): Promise<ExportResult> {
jobs.add(request.jobId)
const send = (progress: ExportJobProgress): void => {
@@ -1674,7 +1760,8 @@ async function runAllExport(
export async function runExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
): Promise<ExportResult> {
return request.scope === 'all'
? runAllExport(request, win, voiceRecognition)
@@ -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
+109 -32
View File
@@ -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')
}
}
+12
View File
@@ -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
+16 -1
View File
@@ -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
})
}
+70
View File
@@ -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<string, string>()
@@ -70,6 +77,69 @@ export class VoiceService {
return { success: true, data: base64Data }
}
async resolveVoices(
references: VoiceReference[]
): Promise<Array<{ success: boolean; data?: string; error?: string }>> {
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)
}))
)
for (const [{ index, reference }, source] of missing.map(
(item, sourceIndex) => [item, sources[sourceIndex]] as const
)) {
if (!source?.success || !source.hex) {
results[index] = { success: false, error: source?.error || '获取语音数据失败' }
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 解码失败'
}
}
}
return results as Array<{ success: boolean; data?: string; error?: string }>
}
async resolvePcm(
sessionId: string,
localId: number,
+112 -17
View File
@@ -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<Wcdb4VoiceDataResult>((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<Wcdb4VoiceDataResult[]> {
if (!requests.length) return []
if (!this.wcdbGetVoiceDataBatch) {
return this.getVoiceDataIndividually(requests)
}
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
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<Wcdb4VoiceDataResult[]> {
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)'