feat: 语音

This commit is contained in:
电摇小子
2026-08-05 09:45:59 +08:00
committed by Wxw-Gu
parent 69bc6f57e7
commit 7529a67f09
49 changed files with 3013 additions and 154 deletions
+45 -1
View File
@@ -102,11 +102,55 @@ describe('export media', () => {
expect(html).toContain('class="file-attachment" href="')
expect(html).toContain('class="quote-reference"')
expect(html).toContain('message.exportMediaError')
expect(html).toContain('.audio-wrap { width: 260px; max-width: 100%; min-width: 0; }')
expect(html).toContain('.audio-wrap { width: 380px; max-width: 100%; min-width: 0; }')
expect(html).toContain('.audio { display: block; width: 100%; max-width: 100%; height: 38px; }')
expect(html).toContain('class="voice-transcript"')
expect(html).toContain('message.voiceTranscript')
expect(html).toContain('class="message-stack"')
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
})
it('renders a voice transcript below audio inside the same exported bubble', () => {
const html = renderExportPage('语音转写档案')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '语音转写档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages: [
{
id: 'voice-transcript',
from: 'user',
type: '语音',
datetime: '2026-08-04 14:26',
content: '[语音消息]',
isSender: true,
voiceDataUrl: 'voices/fixture.wav',
voiceTranscript: '试一下',
createTime: 1_785_549_600
}
]
}
})
dom.window.eval(inlineScriptOf(html))
const stack = dom.window.document.querySelector('.message-stack')!
const bubble = stack.querySelector('.bubble')!
const transcript = stack.querySelector('.voice-transcript')!
expect(bubble.querySelector('audio')?.getAttribute('src')).toBe('voices/fixture.wav')
expect(transcript.textContent).toBe('试一下')
expect(bubble.contains(transcript)).toBe(true)
expect(stack.children).toHaveLength(1)
expect(
bubble.querySelector('audio')!.compareDocumentPosition(transcript) &
dom.window.Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(bubble.textContent).not.toContain('[语音消息]')
dom.window.close()
})
it('renders explicit and keyboard-accessible lightbox closing controls', () => {
const html = renderExportPage('图片预览')
+29 -1
View File
@@ -5,10 +5,11 @@ import { dirname, join, resolve } from 'path'
import { afterAll, describe, expect, it } from 'vitest'
const nodeRequire = createRequire(import.meta.url)
const { validateFfmpegRuntime, validateSilkWasmRuntime } = nodeRequire(
const { validateFfmpegRuntime, validateSherpaRuntime, validateSilkWasmRuntime } = nodeRequire(
'../../scripts/after-pack.cjs'
) as {
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
validateSilkWasmRuntime: (runtimeResources: string) => void
}
const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-'))
@@ -49,4 +50,31 @@ describe('production runtime packaging', () => {
const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8')
expect(config).toContain('node_modules/ffmpeg-static/**')
})
it('requires the matching Windows and macOS sherpa native runtime', () => {
const resources = join(root, 'sherpa-resources')
const unpacked = join(resources, 'app.asar.unpacked', 'node_modules')
const base = join(unpacked, 'sherpa-onnx-node')
mkdirSync(base, { recursive: true })
writeFileSync(join(base, 'package.json'), '{}')
writeFileSync(join(base, 'sherpa-onnx.js'), 'module.exports = {}')
expect(() => validateSherpaRuntime(resources, 'win32', 'x64')).toThrow(/win-x64/)
const windows = join(unpacked, 'sherpa-onnx-win-x64')
mkdirSync(windows, { recursive: true })
writeFileSync(join(windows, 'package.json'), '{}')
writeFileSync(join(windows, 'sherpa-onnx.node'), 'fixture')
expect(() => validateSherpaRuntime(resources, 'win32', 'x64')).not.toThrow()
expect(() => validateSherpaRuntime(resources, 'darwin', 'arm64')).toThrow(/darwin-arm64/)
const mac = join(unpacked, 'sherpa-onnx-darwin-arm64')
mkdirSync(mac, { recursive: true })
writeFileSync(join(mac, 'package.json'), '{}')
writeFileSync(join(mac, 'sherpa-onnx.node'), 'fixture')
expect(() => validateSherpaRuntime(resources, 'darwin', 'arm64')).not.toThrow()
const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8')
expect(config).toContain('node_modules/sherpa-onnx-node/**')
expect(config).toContain('node_modules/sherpa-onnx-*/**')
})
})
+141
View File
@@ -0,0 +1,141 @@
import { mkdtempSync, rmSync } from 'fs'
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 { 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'
import { SENSEVOICE_MODEL_FILES } from '../../src/main/voice-pipeline/model-manager'
const root = mkdtempSync(join(tmpdir(), 'wxe-voice-pipeline-'))
describe('SenseVoice model manifest', () => {
it('uses the Git LFS content digest rather than the Hugging Face xet hash', () => {
expect(SENSEVOICE_MODEL_FILES[0]).toMatchObject({
name: 'model.int8.onnx',
size: 239_233_841,
sha256: 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51'
})
expect(SENSEVOICE_MODEL_FILES[0].sha256).not.toBe(
'c45ba1d6a13329c4aca1dc118cabdc643ca09cb8192abb979648dd68f9917323'
)
})
})
function pcm16(samples: number[]): Buffer {
const buffer = Buffer.alloc(samples.length * 2)
samples.forEach((sample, index) => buffer.writeInt16LE(sample, index * 2))
return buffer
}
describe('PCM audio processing', () => {
it('really resamples 24 kHz PCM to 16 kHz and trims outer silence', () => {
const silence = Array.from({ length: 2400 }, () => 0)
const tone = Array.from({ length: 24000 }, (_, index) =>
Math.round(Math.sin((index / 24000) * Math.PI * 440 * 2) * 20000)
)
const processor = new PcmAudioProcessor({ silencePaddingMs: 0 })
const output = processor.process({
pcm: pcm16([...silence, ...tone, ...silence]),
sampleRate: 24000,
channels: 1,
sourceHash: 'fixture-audio'
})
expect(output.sampleRate).toBe(16000)
expect(output.samples.length).toBeGreaterThan(15900)
expect(output.samples.length).toBeLessThanOrEqual(16000)
expect(output.durationMs).toBeGreaterThanOrEqual(990)
expect(Math.max(...output.samples)).toBeLessThanOrEqual(0.92)
})
it('returns an empty signal when the source only contains silence', () => {
const output = new PcmAudioProcessor().process({
pcm: pcm16(Array.from({ length: 2400 }, () => 0)),
sampleRate: 24000,
channels: 1,
sourceHash: 'silence'
})
expect(output.samples).toHaveLength(0)
})
})
describe('voice task scheduling', () => {
it('runs recognition tasks serially', async () => {
const scheduler = new VoiceTaskScheduler()
const order: string[] = []
let releaseFirst: (() => void) | undefined
const first = scheduler.schedule('first', async () => {
order.push('first:start')
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
order.push('first:end')
return 1
})
const second = scheduler.schedule('second', async () => {
order.push('second')
return 2
})
await vi.waitFor(() => expect(order).toEqual(['first:start']))
releaseFirst?.()
await expect(Promise.all([first, second])).resolves.toEqual([1, 2])
expect(order).toEqual(['first:start', 'first:end', 'second'])
})
it('cancels a queued task without running it', async () => {
const scheduler = new VoiceTaskScheduler()
let releaseFirst: (() => void) | undefined
const first = scheduler.schedule(
'first',
() =>
new Promise<void>((resolve) => {
releaseFirst = resolve
})
)
const queued = scheduler.schedule('queued', async () => 'should-not-run')
expect(scheduler.cancel('queued')).toBe(true)
await expect(queued).rejects.toMatchObject({ name: 'AbortError' })
releaseFirst?.()
await first
})
})
describe('transcript repository', () => {
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('keeps records isolated by account and model fingerprint', () => {
const repository = new SqliteTranscriptRepository(join(root, 'transcripts.sqlite'))
const record: TranscriptRecord = {
accountId: 'account-a',
messageIdentity: 'message-1',
audioHash: 'audio-1',
processorVersion: 'processor-v1',
recognizerId: 'sensevoice',
modelVersion: 'model-v1',
modelFingerprint: 'fingerprint-a',
transcript: '固定测试文本',
language: 'zh',
durationMs: 1200,
createdAt: 1,
updatedAt: 1
}
repository.save(record)
const key = {
accountId: record.accountId,
messageIdentity: record.messageIdentity,
audioHash: record.audioHash,
processorVersion: record.processorVersion,
recognizerId: record.recognizerId,
modelVersion: record.modelVersion,
modelFingerprint: record.modelFingerprint
}
expect(repository.find(key)).toMatchObject({ transcript: '固定测试文本' })
expect(repository.find({ ...key, accountId: 'account-b' })).toBeNull()
expect(repository.find({ ...key, modelFingerprint: 'fingerprint-b' })).toBeNull()
repository.close()
})
})