mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 语音
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ExportTaskCenter } from '../../src/renderer/src/components/export/ExportTaskCenter'
|
||||
|
||||
describe('export task center', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
beforeEach(() => {
|
||||
writeText.mockClear()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the failure reason and copies a diagnostic log', async () => {
|
||||
render(
|
||||
<ExportTaskCenter
|
||||
open
|
||||
taskCount={0}
|
||||
tasks={[
|
||||
{
|
||||
jobId: 'failed-export',
|
||||
contactId: 'fixture',
|
||||
contactName: '脱敏会话',
|
||||
format: 'html',
|
||||
status: 'failed',
|
||||
progress: {
|
||||
jobId: 'failed-export',
|
||||
phase: 'failed',
|
||||
processed: 0,
|
||||
percent: 15,
|
||||
error: 'EPERM: operation not permitted, copyfile'
|
||||
},
|
||||
createdAt: new Date('2026-08-04T15:00:00.000Z').getTime()
|
||||
}
|
||||
]}
|
||||
onToggle={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('EPERM: operation not permitted, copyfile')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: '复制日志' }))
|
||||
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
expect(writeText.mock.calls[0][0]).toContain('会话:脱敏会话')
|
||||
expect(writeText.mock.calls[0][0]).toContain('EPERM: operation not permitted, copyfile')
|
||||
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ExportWorkspace } from '../../src/renderer/src/components/export/ExportWorkspace'
|
||||
import type { VoiceModelStatus } from '../../src/shared/voice-recognition'
|
||||
|
||||
const readyStatus: VoiceModelStatus = {
|
||||
modelId: 'sensevoice-small-int8',
|
||||
version: '2024-07-17',
|
||||
state: 'ready',
|
||||
downloadedBytes: 239_549_735,
|
||||
totalBytes: 239_549_735,
|
||||
progress: 1,
|
||||
platform: 'win32',
|
||||
architecture: 'x64',
|
||||
supported: true
|
||||
}
|
||||
|
||||
describe('export voice transcripts', () => {
|
||||
beforeEach(() => {
|
||||
window.api = {
|
||||
getVoiceModelStatus: vi.fn().mockResolvedValue(readyStatus),
|
||||
onExportProgress: vi.fn(() => vi.fn())
|
||||
} as typeof window.api
|
||||
})
|
||||
|
||||
it('enables voice transcription by default for a ready HTML voice export', async () => {
|
||||
const onStartExport = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
messageCount: 1,
|
||||
outputPath: 'C:\\fixture\\index.html'
|
||||
})
|
||||
render(
|
||||
<ExportWorkspace
|
||||
contacts={[
|
||||
{
|
||||
m_nsUsrName: 'filehelper',
|
||||
m_nsNickName: '文件传输助手',
|
||||
md5: 'fixture-contact',
|
||||
type: 'user'
|
||||
}
|
||||
]}
|
||||
selectedContact={null}
|
||||
previewMessages={[]}
|
||||
selfInfo={null}
|
||||
dbReady
|
||||
onSelectContact={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
exportTasks={[]}
|
||||
onStartExport={onStartExport}
|
||||
onCancelExport={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /HTML/ })[0])
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: '语音' }))
|
||||
|
||||
const transcriptOption = await screen.findByRole('checkbox', {
|
||||
name: '语音转文字,显示在语音条下方'
|
||||
})
|
||||
expect(transcriptOption).toBeEnabled()
|
||||
expect(transcriptOption).toBeChecked()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
|
||||
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
|
||||
expect(onStartExport.mock.calls[0][0]).toMatchObject({
|
||||
format: 'html',
|
||||
includeVoiceTranscripts: true,
|
||||
kinds: expect.arrayContaining(['voice'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,28 @@ describe('VoicePlayer', () => {
|
||||
getVoiceData: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||||
})
|
||||
}),
|
||||
getVoiceModelStatus: vi.fn().mockResolvedValue({
|
||||
modelId: 'sensevoice-small-int8',
|
||||
version: 'fixture',
|
||||
state: 'ready',
|
||||
downloadedBytes: 10,
|
||||
totalBytes: 10,
|
||||
progress: 1,
|
||||
platform: 'win32',
|
||||
architecture: 'x64',
|
||||
supported: true
|
||||
}),
|
||||
recognizeVoice: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
transcript: '这是固定的测试转写',
|
||||
language: 'zh',
|
||||
cached: false
|
||||
}),
|
||||
downloadVoiceModel: vi.fn(),
|
||||
cancelVoiceModelDownload: vi.fn(),
|
||||
cancelVoiceRecognition: vi.fn(),
|
||||
onVoiceModelProgress: vi.fn(() => vi.fn())
|
||||
} as typeof window.api
|
||||
})
|
||||
|
||||
@@ -44,4 +65,42 @@ describe('VoicePlayer', () => {
|
||||
expect(container.querySelector('.voice-icon')).toHaveClass('playing')
|
||||
expect(screen.queryByText('当前版本暂不支持播放')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recognizes one voice message and renders the transcript', async () => {
|
||||
render(<VoicePlayer sessionId="filehelper" localId={11} createTime={1785553200} duration={1} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: '转文字' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(window.api.recognizeVoice).toHaveBeenCalledWith({
|
||||
sessionId: 'filehelper',
|
||||
localId: 11,
|
||||
createTime: 1785553200,
|
||||
svrId: undefined
|
||||
})
|
||||
)
|
||||
expect(await screen.findByText('这是固定的测试转写')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens centralized settings when recognition assets are missing', async () => {
|
||||
vi.mocked(window.api.getVoiceModelStatus).mockResolvedValue({
|
||||
modelId: 'sensevoice-small-int8',
|
||||
version: 'fixture',
|
||||
state: 'missing',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 239_549_735,
|
||||
progress: 0,
|
||||
platform: 'win32',
|
||||
architecture: 'x64',
|
||||
supported: true
|
||||
})
|
||||
render(<VoicePlayer sessionId="filehelper" localId={12} createTime={1785553300} duration={2} />)
|
||||
const openSettings = vi.fn()
|
||||
window.addEventListener('wxe:open-voice-recognition-settings', openSettings, { once: true })
|
||||
await userEvent.click(screen.getByRole('button', { name: '转文字' }))
|
||||
|
||||
expect(await screen.findByText(/请先在设置中准备离线语音模型/)).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: '前往设置' }))
|
||||
expect(openSettings).toHaveBeenCalledOnce()
|
||||
expect(window.api.recognizeVoice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { VoiceRecognitionPage } from '../../src/renderer/src/features/settings/pages/VoiceRecognitionPage'
|
||||
import type { VoiceModelStatus } from '../../src/shared/voice-recognition'
|
||||
|
||||
const readyStatus: VoiceModelStatus = {
|
||||
modelId: 'sensevoice-small-int8',
|
||||
version: '2024-07-17',
|
||||
state: 'ready',
|
||||
downloadedBytes: 239_549_735,
|
||||
totalBytes: 239_549_735,
|
||||
progress: 1,
|
||||
platform: 'win32',
|
||||
architecture: 'x64',
|
||||
supported: true
|
||||
}
|
||||
|
||||
describe('voice recognition settings', () => {
|
||||
beforeEach(() => {
|
||||
window.api = {
|
||||
getVoiceModelStatus: vi.fn().mockResolvedValue(readyStatus),
|
||||
downloadVoiceModel: vi.fn(),
|
||||
cancelVoiceModelDownload: vi.fn(),
|
||||
removeVoiceModel: vi.fn().mockResolvedValue({ ...readyStatus, state: 'missing' }),
|
||||
openVoiceModelDirectory: vi.fn().mockResolvedValue({ success: true }),
|
||||
onVoiceModelProgress: vi.fn(() => vi.fn())
|
||||
} as typeof window.api
|
||||
})
|
||||
|
||||
it('shows Windows runtime and installed model actions', async () => {
|
||||
render(<VoiceRecognitionPage onNotice={vi.fn()} />)
|
||||
expect(await screen.findByText('Windows 64 位')).toBeInTheDocument()
|
||||
expect(screen.queryByText('额外环境')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'SenseVoice(MIT)' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'sherpa-onnx(Apache-2.0)' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '打开模型目录' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '删除模型' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads the model from the centralized settings page', async () => {
|
||||
const missing = { ...readyStatus, state: 'missing' as const, progress: 0, downloadedBytes: 0 }
|
||||
vi.mocked(window.api.getVoiceModelStatus).mockResolvedValue(missing)
|
||||
vi.mocked(window.api.downloadVoiceModel).mockResolvedValue({
|
||||
success: true,
|
||||
status: readyStatus
|
||||
})
|
||||
const notice = vi.fn()
|
||||
render(<VoiceRecognitionPage onNotice={notice} />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: '下载模型' }))
|
||||
|
||||
await waitFor(() => expect(window.api.downloadVoiceModel).toHaveBeenCalledOnce())
|
||||
expect(notice).toHaveBeenCalledWith('离线语音模型已准备好')
|
||||
})
|
||||
|
||||
it('shows download percentage in the header and model card', async () => {
|
||||
const missing = {
|
||||
...readyStatus,
|
||||
state: 'missing' as const,
|
||||
progress: 0,
|
||||
downloadedBytes: 0
|
||||
}
|
||||
let progressListener: ((status: VoiceModelStatus) => void) | undefined
|
||||
let finishDownload:
|
||||
| ((value: { success: boolean; status: VoiceModelStatus }) => void)
|
||||
| undefined
|
||||
vi.mocked(window.api.getVoiceModelStatus).mockResolvedValue(missing)
|
||||
vi.mocked(window.api.onVoiceModelProgress).mockImplementation((listener) => {
|
||||
progressListener = listener
|
||||
return vi.fn()
|
||||
})
|
||||
vi.mocked(window.api.downloadVoiceModel).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishDownload = resolve
|
||||
})
|
||||
)
|
||||
render(<VoiceRecognitionPage onNotice={vi.fn()} />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: '下载模型' }))
|
||||
progressListener?.({
|
||||
...missing,
|
||||
state: 'downloading',
|
||||
downloadedBytes: Math.round(missing.totalBytes * 0.42),
|
||||
progress: 0.42
|
||||
})
|
||||
|
||||
expect(await screen.findByText('下载中 42%')).toBeInTheDocument()
|
||||
expect(screen.getByText('正在下载 42%')).toBeInTheDocument()
|
||||
finishDownload?.({ success: true, status: readyStatus })
|
||||
})
|
||||
})
|
||||
@@ -223,6 +223,24 @@ handle('db:getImage', (md5, datName, sessionId, options) =>
|
||||
}
|
||||
)
|
||||
handle('db:getVoiceData', () => ({ success: true, data: voiceData }))
|
||||
const voiceModelStatus = (state = 'missing') => ({
|
||||
modelId: 'sensevoice-small-int8',
|
||||
version: '2024-07-17',
|
||||
state,
|
||||
downloadedBytes: state === 'ready' ? 239549735 : 0,
|
||||
totalBytes: 239549735,
|
||||
progress: state === 'ready' ? 1 : 0,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
supported: process.platform === 'win32' || process.platform === 'darwin'
|
||||
})
|
||||
handle('voice:getModelStatus', () => voiceModelStatus())
|
||||
handle('voice:downloadModel', () => ({ success: true, status: voiceModelStatus('ready') }))
|
||||
handle('voice:cancelModelDownload', () => ({ success: true }))
|
||||
handle('voice:removeModel', () => voiceModelStatus())
|
||||
handle('voice:openModelDirectory', () => ({ success: true }))
|
||||
handle('voice:recognize', () => ({ success: true, transcript: '固定脱敏转写文本', language: 'zh' }))
|
||||
handle('voice:cancelRecognition', () => ({ success: true }))
|
||||
handle('db:getSticker', (url) =>
|
||||
String(url || '').includes('403')
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dirname, join } from 'path'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import fsExtra from 'fs-extra'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
@@ -207,11 +208,20 @@ describe('media export flow', () => {
|
||||
outputName: 'fixture',
|
||||
kinds: ['voice', 'image', 'video', 'file'],
|
||||
includeMedia: true,
|
||||
includeVoiceTranscripts: true,
|
||||
preferOriginal: true,
|
||||
fallbackThumbnail: true,
|
||||
keepMissing: true
|
||||
},
|
||||
win as never
|
||||
win as never,
|
||||
{
|
||||
recognize: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
transcript: '这是导出的固定语音转写',
|
||||
language: 'zh',
|
||||
cached: true
|
||||
})
|
||||
} as never
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
@@ -231,6 +241,7 @@ describe('media export flow', () => {
|
||||
expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容')
|
||||
expect(html).toContain('<script src="data/messages.js"></script>')
|
||||
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
|
||||
expect(voice.voiceTranscript).toBe('这是导出的固定语音转写')
|
||||
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
|
||||
expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/)
|
||||
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
|
||||
@@ -302,6 +313,52 @@ describe('media export flow', () => {
|
||||
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true)
|
||||
})
|
||||
|
||||
it('reuses existing video and file assets when Windows rejects an overwrite', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||
const request = {
|
||||
userMd5: 'fixture-user',
|
||||
name: '媒体复用会话',
|
||||
format: 'html' as const,
|
||||
outputName: 'reused-media-fixture',
|
||||
kinds: ['video', 'file'] as const,
|
||||
includeMedia: true,
|
||||
keepMissing: true
|
||||
}
|
||||
|
||||
const first = await runExport(
|
||||
{ ...request, jobId: 'media-reuse-first', kinds: [...request.kinds] },
|
||||
win as never
|
||||
)
|
||||
expect(first.success).toBe(true)
|
||||
|
||||
const originalCopyFile = fsExtra.copyFile.bind(fsExtra)
|
||||
const copyFile = vi
|
||||
.spyOn(fsExtra, 'copyFile')
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
|
||||
)
|
||||
.mockImplementation(originalCopyFile)
|
||||
|
||||
const second = await runExport(
|
||||
{ ...request, jobId: 'media-reuse-second', kinds: [...request.kinds] },
|
||||
win as never
|
||||
)
|
||||
copyFile.mockRestore()
|
||||
|
||||
expect(second.success).toBe(true)
|
||||
const archive = readArchive(second.outputPath!)
|
||||
expect(archive.messages.find((item) => item.id === 'video')?.exportMediaUrl).toMatch(
|
||||
/^media\/video_/
|
||||
)
|
||||
expect(archive.messages.find((item) => item.id === 'file')?.exportMediaUrl).toMatch(
|
||||
/^media\/file_/
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to merge a different conversation into an existing named archive', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||
|
||||
@@ -48,6 +48,23 @@ describe('preload IPC contract', () => {
|
||||
'fixture-session',
|
||||
{ force: true, priority: 0 }
|
||||
)
|
||||
|
||||
const voiceReference = {
|
||||
sessionId: 'filehelper',
|
||||
localId: 11,
|
||||
createTime: 1785553200,
|
||||
svrId: 'server-11'
|
||||
}
|
||||
await api.recognizeVoice(voiceReference)
|
||||
expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference)
|
||||
await api.cancelVoiceRecognition(voiceReference)
|
||||
expect(invoke).toHaveBeenLastCalledWith('voice:cancelRecognition', voiceReference)
|
||||
await api.downloadVoiceModel()
|
||||
expect(invoke).toHaveBeenLastCalledWith('voice:downloadModel')
|
||||
await api.removeVoiceModel()
|
||||
expect(invoke).toHaveBeenLastCalledWith('voice:removeModel')
|
||||
await api.openVoiceModelDirectory()
|
||||
expect(invoke).toHaveBeenLastCalledWith('voice:openModelDirectory')
|
||||
})
|
||||
|
||||
it('preserves key API return values without exposing ipcRenderer', async () => {
|
||||
|
||||
@@ -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('图片预览')
|
||||
|
||||
|
||||
@@ -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-*/**')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user