Files
WechatExplorer/src/main/voice-pipeline/audio-processor.ts
T
Nanin 34b86af0be perf: 加速语音转写缓存命中
迁移旧版语音转写缓存,并将补迁失败降级为一次性失败状态。

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

补充迁移、缓存快速路径、批量语音读取和导出流程测试。
2026-08-12 20:01:40 +08:00

92 lines
3.4 KiB
TypeScript

import type { AudioProcessor, PipelineAudio } from './types'
export const VOICE_PROCESSOR_VERSION = 'pcm16-mono-16k-v1'
export interface PcmProcessorOptions {
targetSampleRate?: number
silenceThreshold?: number
silencePaddingMs?: number
normalizePeak?: number
}
export class PcmAudioProcessor implements AudioProcessor {
readonly version = VOICE_PROCESSOR_VERSION
private readonly targetSampleRate: number
private readonly silenceThreshold: number
private readonly silencePaddingMs: number
private readonly normalizePeak: number
constructor(options: PcmProcessorOptions = {}) {
this.targetSampleRate = options.targetSampleRate ?? 16000
this.silenceThreshold = options.silenceThreshold ?? 0.008
this.silencePaddingMs = options.silencePaddingMs ?? 80
this.normalizePeak = options.normalizePeak ?? 0.92
}
process(input: {
pcm: Buffer
sampleRate: number
channels: number
sourceHash: string
}): PipelineAudio {
if (input.channels !== 1) throw new Error('Only mono PCM is supported')
if (input.pcm.length < 2) throw new Error('PCM audio is empty')
const decoded = this.decodePcm16(input.pcm)
const trimmed = this.trimSilence(decoded, input.sampleRate)
const resampled = this.resample(trimmed, input.sampleRate, this.targetSampleRate)
const normalized = this.normalize(resampled)
return {
samples: normalized,
sampleRate: this.targetSampleRate,
channels: 1,
sourceHash: input.sourceHash,
processorVersion: VOICE_PROCESSOR_VERSION,
durationMs: Math.round((normalized.length / this.targetSampleRate) * 1000)
}
}
private decodePcm16(buffer: Buffer): Float32Array {
const output = new Float32Array(Math.floor(buffer.length / 2))
for (let index = 0; index < output.length; index += 1) {
output[index] = buffer.readInt16LE(index * 2) / 32768
}
return output
}
private trimSilence(samples: Float32Array, sampleRate: number): Float32Array {
let first = 0
while (first < samples.length && Math.abs(samples[first]) < this.silenceThreshold) first += 1
if (first === samples.length) return new Float32Array(0)
let last = samples.length - 1
while (last > first && Math.abs(samples[last]) < this.silenceThreshold) last -= 1
const padding = Math.round((sampleRate * this.silencePaddingMs) / 1000)
return samples.slice(Math.max(0, first - padding), Math.min(samples.length, last + padding + 1))
}
private resample(samples: Float32Array, sourceRate: number, targetRate: number): Float32Array {
if (sourceRate === targetRate || samples.length === 0) return samples.slice()
const outputLength = Math.max(1, Math.round((samples.length * targetRate) / sourceRate))
const output = new Float32Array(outputLength)
const ratio = sourceRate / targetRate
for (let index = 0; index < outputLength; index += 1) {
const position = index * ratio
const left = Math.min(samples.length - 1, Math.floor(position))
const right = Math.min(samples.length - 1, left + 1)
const fraction = position - left
output[index] = samples[left] + (samples[right] - samples[left]) * fraction
}
return output
}
private normalize(samples: Float32Array): Float32Array {
let peak = 0
for (const sample of samples) peak = Math.max(peak, Math.abs(sample))
if (peak < 0.001 || peak <= this.normalizePeak) return samples
const scale = this.normalizePeak / peak
return samples.map((sample) => sample * scale)
}
}