chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
speechModelCatalogEntrySchema,
|
||||
type SpeechModelCatalogEntry
|
||||
} from '../../shared/speech-model-contracts'
|
||||
|
||||
/**
|
||||
* This catalog intentionally contains metadata only. Model weights are never
|
||||
* bundled with GoodBuddy. Entries remain manual-only until every downloadable
|
||||
* file has a pinned revision, byte size, and independently verified SHA-256.
|
||||
*/
|
||||
export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
speechModelCatalogEntrySchema.array().parse([
|
||||
{
|
||||
id: 'sensevoice-small-int8',
|
||||
displayName: 'SenseVoiceSmall INT8',
|
||||
description:
|
||||
'快速中文语音识别,兼顾粤语、英语、日语和韩语,适合本地 CPU 使用。',
|
||||
languages: ['中文', '粤语', '英语', '日语', '韩语'],
|
||||
family: 'sensevoice',
|
||||
quantization: 'int8',
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17',
|
||||
license: {
|
||||
name: '模型仓库自定义许可(Model License)',
|
||||
notice:
|
||||
'SenseVoiceSmall 权重采用模型仓库声明的自定义 MODEL LICENSE,并非 Apache-2.0 或 MIT;导入和使用前请阅读完整许可条款。',
|
||||
url: 'https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: [
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/' +
|
||||
'resolve/6a65851692da9706cbddfac66ea9b96ebb1dee21/' +
|
||||
'model.int8.onnx',
|
||||
size: 239_233_841,
|
||||
sha256:
|
||||
'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/' +
|
||||
'resolve/86f7114c4a83bcba0c609dd8d8dfa730d967ade9/' +
|
||||
'tokens.txt',
|
||||
size: 315_894,
|
||||
sha256:
|
||||
'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'whisper-tiny-multilingual',
|
||||
displayName: 'Whisper Tiny(多语言)',
|
||||
description:
|
||||
'OpenAI Whisper Tiny 多语言备选,体积较小,支持中文及多种语言。',
|
||||
languages: ['中文', '英语', '多语言'],
|
||||
family: 'whisper',
|
||||
quantization: 'int8',
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-tiny',
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice:
|
||||
'Whisper 模型由 OpenAI 以 MIT License 发布;转换后的文件应同时遵守上游仓库随附说明。',
|
||||
url: 'https://github.com/openai/whisper/blob/main/LICENSE'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: [
|
||||
{
|
||||
name: 'tiny-encoder.int8.onnx',
|
||||
role: 'encoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'65176e2deb88badc814a94058666cadccc29b61c/' +
|
||||
'tiny-encoder.int8.onnx',
|
||||
size: 12_937_772,
|
||||
sha256:
|
||||
'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tiny-decoder.int8.onnx',
|
||||
role: 'decoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'65176e2deb88badc814a94058666cadccc29b61c/' +
|
||||
'tiny-decoder.int8.onnx',
|
||||
size: 89_855_401,
|
||||
sha256:
|
||||
'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tiny-tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'65176e2deb88badc814a94058666cadccc29b61c/' +
|
||||
'tiny-tokens.txt',
|
||||
size: 816_730,
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
export function getSpeechModelCatalogEntry(
|
||||
modelId: string
|
||||
): SpeechModelCatalogEntry | undefined {
|
||||
return SPEECH_MODEL_CATALOG.find((entry) => entry.id === modelId)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SpeechModelCatalogEntry } from '../../shared/speech-model-contracts'
|
||||
import { SPEECH_MODEL_CATALOG } from './speech-model-catalog'
|
||||
import { SpeechModelManager } from './speech-model-manager'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-speech-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
function sha256(value: Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
function manualCatalog(): SpeechModelCatalogEntry[] {
|
||||
return SPEECH_MODEL_CATALOG.map((entry) => ({
|
||||
...entry,
|
||||
manualOnly: true,
|
||||
manualReason: entry.manualReason ?? '测试使用本地目录导入。',
|
||||
files: entry.files.map(({ name, role }) => ({ name, role }))
|
||||
}))
|
||||
}
|
||||
|
||||
function downloadableCatalog(
|
||||
modelBytes: Uint8Array,
|
||||
tokenBytes: Uint8Array = new TextEncoder().encode('tokens')
|
||||
): SpeechModelCatalogEntry[] {
|
||||
return [
|
||||
{
|
||||
id: 'download-test-model',
|
||||
displayName: 'Download test model',
|
||||
description: 'Download model used by manager tests.',
|
||||
languages: ['中文'],
|
||||
family: 'whisper',
|
||||
quantization: 'int8',
|
||||
repositoryUrl: 'https://huggingface.co/example/download-test-model',
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Test-only model metadata.',
|
||||
url: 'https://opensource.org/license/mit'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/example/download-test-model/' +
|
||||
'resolve/revision/model.onnx',
|
||||
size: modelBytes.byteLength,
|
||||
sha256: sha256(modelBytes)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/example/download-test-model/' +
|
||||
'resolve/revision/tokens.txt',
|
||||
size: tokenBytes.byteLength,
|
||||
sha256: sha256(tokenBytes)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('speech model catalog', () => {
|
||||
it('lists metadata only and accurately labels SenseVoice custom licensing', () => {
|
||||
const senseVoice = SPEECH_MODEL_CATALOG.find(
|
||||
(entry) => entry.id === 'sensevoice-small-int8'
|
||||
)
|
||||
const whisper = SPEECH_MODEL_CATALOG.find(
|
||||
(entry) => entry.id === 'whisper-tiny-multilingual'
|
||||
)
|
||||
|
||||
expect(senseVoice).toMatchObject({
|
||||
manualOnly: false,
|
||||
family: 'sensevoice',
|
||||
quantization: 'int8',
|
||||
license: {
|
||||
name: expect.stringContaining('自定义许可')
|
||||
}
|
||||
})
|
||||
expect(senseVoice?.license.notice).toContain('并非 Apache-2.0 或 MIT')
|
||||
expect(whisper).toMatchObject({
|
||||
manualOnly: false,
|
||||
family: 'whisper',
|
||||
quantization: 'int8',
|
||||
license: { name: 'MIT License' }
|
||||
})
|
||||
expect(
|
||||
senseVoice?.files.every((file) => file.download !== undefined)
|
||||
).toBe(true)
|
||||
expect(whisper?.files.every((file) => file.download !== undefined))
|
||||
.toBe(true)
|
||||
expect(whisper?.files.map((file) => file.name)).toEqual([
|
||||
'tiny-encoder.int8.onnx',
|
||||
'tiny-decoder.int8.onnx',
|
||||
'tiny-tokens.txt'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechModelManager downloads', () => {
|
||||
it('downloads to partial files, verifies hashes, and atomically installs', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('verified model bytes')
|
||||
const tokenBytes = new TextEncoder().encode('verified tokens')
|
||||
const catalog = downloadableCatalog(modelBytes, tokenBytes)
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input)
|
||||
const bytes = url.endsWith('model.onnx')
|
||||
? modelBytes
|
||||
: tokenBytes
|
||||
return new Response(bytes, {
|
||||
headers: { 'content-length': String(bytes.byteLength) }
|
||||
})
|
||||
})
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
fetch: transport,
|
||||
catalog
|
||||
})
|
||||
|
||||
const installed = await manager.install('download-test-model')
|
||||
|
||||
expect(installed).toMatchObject({
|
||||
id: 'download-test-model',
|
||||
source: 'download',
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
size: modelBytes.byteLength,
|
||||
sha256: sha256(modelBytes)
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
size: tokenBytes.byteLength,
|
||||
sha256: sha256(tokenBytes)
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(transport).toHaveBeenCalledTimes(2)
|
||||
for (const [input, init] of transport.mock.calls) {
|
||||
expect(String(input)).toMatch(/^https:\/\/huggingface\.co\//u)
|
||||
expect(init).toMatchObject({
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store'
|
||||
})
|
||||
}
|
||||
const modelDirectory = join(
|
||||
userData,
|
||||
'models',
|
||||
'speech',
|
||||
'download-test-model'
|
||||
)
|
||||
expect(await readFile(join(modelDirectory, 'model.onnx'))).toEqual(
|
||||
Buffer.from(modelBytes)
|
||||
)
|
||||
expect(
|
||||
(await readdir(modelDirectory)).some((name) =>
|
||||
name.endsWith('.partial')
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
await manager.select('download-test-model')
|
||||
await expect(manager.snapshot()).resolves.toMatchObject({
|
||||
selectedModelId: 'download-test-model',
|
||||
operations: []
|
||||
})
|
||||
await manager.remove('download-test-model')
|
||||
await expect(manager.snapshot()).resolves.toMatchObject({
|
||||
selectedModelId: null,
|
||||
installed: []
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects untrusted redirects and bad digests without installing', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const catalog = downloadableCatalog(modelBytes)
|
||||
const redirected = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog,
|
||||
fetch: vi.fn<typeof fetch>(async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'https://attacker.invalid/model.onnx'
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
await expect(
|
||||
redirected.install('download-test-model')
|
||||
).rejects.toThrow('允许的 Hugging Face HTTPS')
|
||||
|
||||
const badDigest = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog,
|
||||
fetch: vi.fn<typeof fetch>(async (input) => {
|
||||
const expectedSize = String(input).endsWith('model.onnx')
|
||||
? modelBytes.byteLength
|
||||
: new TextEncoder().encode('tokens').byteLength
|
||||
return new Response(new Uint8Array(expectedSize).fill(1), {
|
||||
headers: { 'content-length': String(expectedSize) }
|
||||
})
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
badDigest.install('download-test-model')
|
||||
).rejects.toThrow('校验失败')
|
||||
await expect(badDigest.snapshot()).resolves.toMatchObject({
|
||||
installed: [],
|
||||
operations: []
|
||||
})
|
||||
expect(
|
||||
(await readdir(join(userData, 'models', 'speech'))).filter(
|
||||
(name) => name.startsWith('.install-')
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels an active download through its AbortSignal', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const catalog = downloadableCatalog(modelBytes)
|
||||
let requestStarted: (() => void) | undefined
|
||||
const started = new Promise<void>((resolveStarted) => {
|
||||
requestStarted = resolveStarted
|
||||
})
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog,
|
||||
fetch: vi.fn<typeof fetch>(
|
||||
async (_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
requestStarted?.()
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(new DOMException('aborted', 'AbortError')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const installing = manager.install('download-test-model')
|
||||
await started
|
||||
expect(manager.cancel('download-test-model')).toBe(true)
|
||||
await expect(installing).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(manager.cancel('download-test-model')).toBe(false)
|
||||
await expect(manager.snapshot()).resolves.toMatchObject({
|
||||
installed: [],
|
||||
operations: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechModelManager local import', () => {
|
||||
it('copies only declared files and rejects executable content', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const source = await temporaryDirectory()
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
fetch: vi.fn<typeof fetch>(),
|
||||
catalog: manualCatalog(),
|
||||
maxFileBytes: 1024
|
||||
})
|
||||
await writeFile(join(source, 'model.int8.onnx'), 'model')
|
||||
await writeFile(join(source, 'tokens.txt'), 'tokens')
|
||||
await writeFile(join(source, 'notes.md'), 'not copied')
|
||||
|
||||
const installed = await manager.registerLocalDirectory(
|
||||
'sensevoice-small-int8',
|
||||
source
|
||||
)
|
||||
|
||||
expect(installed.source).toBe('local')
|
||||
const installedFiles = await readdir(
|
||||
join(
|
||||
userData,
|
||||
'models',
|
||||
'speech',
|
||||
'sensevoice-small-int8'
|
||||
)
|
||||
)
|
||||
expect(installedFiles.sort()).toEqual(
|
||||
['manifest.json', 'model.int8.onnx', 'tokens.txt'].sort()
|
||||
)
|
||||
|
||||
await manager.remove('sensevoice-small-int8')
|
||||
await writeFile(join(source, 'run.exe'), 'not allowed')
|
||||
await expect(
|
||||
manager.registerLocalDirectory(
|
||||
'sensevoice-small-int8',
|
||||
source
|
||||
)
|
||||
).rejects.toThrow('包含可执行文件')
|
||||
})
|
||||
|
||||
it('rejects missing and oversized declared files', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const source = await temporaryDirectory()
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
fetch: vi.fn<typeof fetch>(),
|
||||
catalog: manualCatalog(),
|
||||
maxFileBytes: 4
|
||||
})
|
||||
await mkdir(join(source, 'model.int8.onnx'))
|
||||
await writeFile(join(source, 'tokens.txt'), 'token')
|
||||
|
||||
await expect(
|
||||
manager.registerLocalDirectory(
|
||||
'sensevoice-small-int8',
|
||||
source
|
||||
)
|
||||
).rejects.toThrow('普通文件')
|
||||
|
||||
await rm(join(source, 'model.int8.onnx'), { recursive: true })
|
||||
await writeFile(join(source, 'model.int8.onnx'), '12345')
|
||||
await expect(
|
||||
manager.registerLocalDirectory(
|
||||
'sensevoice-small-int8',
|
||||
source
|
||||
)
|
||||
).rejects.toThrow('大小无效')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,809 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
installedSpeechModelSchema,
|
||||
speechModelCatalogEntrySchema,
|
||||
speechModelIdSchema,
|
||||
speechModelSnapshotSchema,
|
||||
type InstalledSpeechModel,
|
||||
type SpeechModelCatalogEntry,
|
||||
type SpeechModelFileSpec,
|
||||
type SpeechModelOperation,
|
||||
type SpeechModelSnapshot
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import { SPEECH_MODEL_CATALOG } from './speech-model-catalog'
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
const MAX_REDIRECTS = 3
|
||||
const MANIFEST_FILE_NAME = 'manifest.json'
|
||||
const SELECTION_FILE_NAME = '.selection.json'
|
||||
const PARTIAL_SUFFIX = '.partial'
|
||||
|
||||
const SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS = new Set([
|
||||
'huggingface.co',
|
||||
'cdn-lfs.huggingface.co',
|
||||
'cdn-lfs-us-1.huggingface.co',
|
||||
'cdn-lfs-eu-1.huggingface.co',
|
||||
'cdn-lfs.hf.co',
|
||||
'cdn-lfs-us-1.hf.co',
|
||||
'cdn-lfs-eu-1.hf.co',
|
||||
'cas-bridge.xethub.hf.co'
|
||||
])
|
||||
|
||||
const selectionSchema = z
|
||||
.object({
|
||||
selectedModelId: speechModelIdSchema.nullable()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const executableExtensionPattern =
|
||||
/\.(?:app|bat|bin|cmd|com|cpl|dll|dmg|exe|gadget|hta|inf|ins|ipa|iso|jar|js|jse|lnk|msi|msp|mst|pif|ps1|reg|scr|sh|sys|vb|vbe|vbs|ws|wsc|wsf|wsh)$/iu
|
||||
|
||||
type ActiveOperation = {
|
||||
controller: AbortController
|
||||
progress: SpeechModelOperation
|
||||
}
|
||||
|
||||
export type SpeechModelManagerOptions = {
|
||||
userDataDirectory: string
|
||||
fetch: typeof fetch
|
||||
catalog?: readonly SpeechModelCatalogEntry[]
|
||||
maxFileBytes?: number
|
||||
}
|
||||
|
||||
export type SelectedSpeechRuntimeModel = {
|
||||
id: string
|
||||
family: SpeechModelCatalogEntry['family']
|
||||
directory: string
|
||||
files: InstalledSpeechModel['files']
|
||||
}
|
||||
|
||||
function cloneCatalogEntry(
|
||||
entry: SpeechModelCatalogEntry
|
||||
): SpeechModelCatalogEntry {
|
||||
return speechModelCatalogEntrySchema.parse(entry)
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
function ensureNotAborted(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
throw abortError()
|
||||
}
|
||||
}
|
||||
|
||||
function validateMaximumBytes(value: number | undefined): number {
|
||||
const maximum = value ?? DEFAULT_MAX_FILE_BYTES
|
||||
if (
|
||||
!Number.isSafeInteger(maximum) ||
|
||||
maximum <= 0 ||
|
||||
maximum > 8 * 1024 * 1024 * 1024
|
||||
) {
|
||||
throw new RangeError('maxFileBytes must be a positive safe integer')
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
function safeChild(parent: string, name: string): string {
|
||||
const child = resolve(parent, name)
|
||||
if (dirname(child) !== resolve(parent)) {
|
||||
throw new Error('模型路径超出受管目录')
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
function validateDownloadUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash ||
|
||||
!SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS.has(url.hostname.toLowerCase())
|
||||
) {
|
||||
throw new Error('模型下载地址必须是允许的 Hugging Face HTTPS 地址')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
async function hashFile(
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{
|
||||
size: number
|
||||
sha256: string
|
||||
}> {
|
||||
const handle = await open(path, 'r')
|
||||
const hash = createHash('sha256')
|
||||
let size = 0
|
||||
const buffer = Buffer.allocUnsafe(64 * 1024)
|
||||
try {
|
||||
while (true) {
|
||||
if (signal) {
|
||||
ensureNotAborted(signal)
|
||||
}
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
hash.update(buffer.subarray(0, bytesRead))
|
||||
size += bytesRead
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
return { size, sha256: hash.digest('hex') }
|
||||
}
|
||||
|
||||
export class SpeechModelManager {
|
||||
readonly rootDirectory: string
|
||||
|
||||
private readonly transport: typeof fetch
|
||||
private readonly catalog: SpeechModelCatalogEntry[]
|
||||
private readonly maxFileBytes: number
|
||||
private readonly operations = new Map<string, ActiveOperation>()
|
||||
|
||||
constructor(options: SpeechModelManagerOptions) {
|
||||
if (!options.userDataDirectory.trim()) {
|
||||
throw new Error('userDataDirectory is required')
|
||||
}
|
||||
this.rootDirectory = resolve(
|
||||
options.userDataDirectory,
|
||||
'models',
|
||||
'speech'
|
||||
)
|
||||
this.transport = options.fetch
|
||||
this.catalog = (options.catalog ?? SPEECH_MODEL_CATALOG).map(
|
||||
cloneCatalogEntry
|
||||
)
|
||||
if (new Set(this.catalog.map((entry) => entry.id)).size !== this.catalog.length) {
|
||||
throw new Error('语音模型目录包含重复 ID')
|
||||
}
|
||||
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
|
||||
}
|
||||
|
||||
async snapshot(): Promise<SpeechModelSnapshot> {
|
||||
await this.ensureRoot()
|
||||
const installed = await this.readInstalled()
|
||||
const selected = await this.readSelection()
|
||||
const installedIds = new Set(installed.map((model) => model.id))
|
||||
return speechModelSnapshotSchema.parse({
|
||||
rootDirectory: this.rootDirectory,
|
||||
catalog: this.catalog.map(cloneCatalogEntry),
|
||||
installed,
|
||||
operations: [...this.operations.values()].map((operation) => ({
|
||||
...operation.progress
|
||||
})),
|
||||
selectedModelId:
|
||||
selected && installedIds.has(selected) ? selected : null
|
||||
})
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<SpeechModelSnapshot> {
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
async getSelectedRuntimeModel(): Promise<
|
||||
SelectedSpeechRuntimeModel | undefined
|
||||
> {
|
||||
const snapshot = await this.snapshot()
|
||||
if (!snapshot.selectedModelId) {
|
||||
return undefined
|
||||
}
|
||||
const catalogEntry = this.catalog.find(
|
||||
(entry) => entry.id === snapshot.selectedModelId
|
||||
)
|
||||
const installed = snapshot.installed.find(
|
||||
(entry) => entry.id === snapshot.selectedModelId
|
||||
)
|
||||
if (!catalogEntry || !installed) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
id: installed.id,
|
||||
family: catalogEntry.family,
|
||||
directory: this.modelDirectory(installed.id),
|
||||
files: installed.files.map((file) => ({ ...file }))
|
||||
}
|
||||
}
|
||||
|
||||
async install(
|
||||
modelId: string,
|
||||
externalSignal?: AbortSignal
|
||||
): Promise<InstalledSpeechModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
if (entry.manualOnly) {
|
||||
throw new Error(
|
||||
entry.manualReason ?? '该模型只能从本地目录导入'
|
||||
)
|
||||
}
|
||||
const downloadableFiles = entry.files.filter(
|
||||
(
|
||||
file
|
||||
): file is SpeechModelFileSpec & {
|
||||
download: NonNullable<SpeechModelFileSpec['download']>
|
||||
} => file.download !== undefined
|
||||
)
|
||||
if (downloadableFiles.length !== entry.files.length) {
|
||||
throw new Error('模型下载元数据不完整')
|
||||
}
|
||||
const totalBytes = downloadableFiles.reduce(
|
||||
(total, file) => total + file.download.size,
|
||||
0
|
||||
)
|
||||
if (!Number.isSafeInteger(totalBytes)) {
|
||||
throw new RangeError('模型总大小超出安全范围')
|
||||
}
|
||||
const operation = this.beginOperation(
|
||||
entry.id,
|
||||
'download',
|
||||
totalBytes
|
||||
)
|
||||
const detachExternalAbort = this.attachExternalSignal(
|
||||
externalSignal,
|
||||
operation.controller
|
||||
)
|
||||
let stagingDirectory: string | undefined
|
||||
try {
|
||||
await this.ensureRoot()
|
||||
await this.assertNotInstalled(entry.id)
|
||||
stagingDirectory = await this.createStagingDirectory(entry.id)
|
||||
for (const file of downloadableFiles) {
|
||||
ensureNotAborted(operation.controller.signal)
|
||||
operation.progress.phase = 'transferring'
|
||||
operation.progress.currentFile = file.name
|
||||
const destination = safeChild(stagingDirectory, file.name)
|
||||
await this.downloadFile(
|
||||
file,
|
||||
destination,
|
||||
operation,
|
||||
operation.controller.signal
|
||||
)
|
||||
}
|
||||
operation.progress.phase = 'installing'
|
||||
operation.progress.currentFile = null
|
||||
const installed = await this.createInstalledManifest(
|
||||
entry,
|
||||
'download',
|
||||
stagingDirectory,
|
||||
operation.controller.signal
|
||||
)
|
||||
ensureNotAborted(operation.controller.signal)
|
||||
await rename(
|
||||
stagingDirectory,
|
||||
this.modelDirectory(entry.id)
|
||||
)
|
||||
stagingDirectory = undefined
|
||||
return installed
|
||||
} finally {
|
||||
detachExternalAbort()
|
||||
this.operations.delete(entry.id)
|
||||
if (stagingDirectory) {
|
||||
await rm(stagingDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancel(modelId: string): boolean {
|
||||
speechModelIdSchema.parse(modelId)
|
||||
const operation = this.operations.get(modelId)
|
||||
if (!operation) {
|
||||
return false
|
||||
}
|
||||
operation.controller.abort()
|
||||
return true
|
||||
}
|
||||
|
||||
async remove(modelId: string): Promise<void> {
|
||||
speechModelIdSchema.parse(modelId)
|
||||
this.cancel(modelId)
|
||||
await this.ensureRoot()
|
||||
const target = this.modelDirectory(modelId)
|
||||
await rm(target, { recursive: true, force: true })
|
||||
const selected = await this.readSelection()
|
||||
if (selected === modelId) {
|
||||
await this.writeSelection(null)
|
||||
}
|
||||
}
|
||||
|
||||
async select(modelId: string | null): Promise<void> {
|
||||
if (modelId !== null) {
|
||||
speechModelIdSchema.parse(modelId)
|
||||
const installed = await this.readInstalled()
|
||||
if (!installed.some((model) => model.id === modelId)) {
|
||||
throw new Error('只能选择已安装的语音模型')
|
||||
}
|
||||
}
|
||||
await this.writeSelection(modelId)
|
||||
}
|
||||
|
||||
async registerLocalDirectory(
|
||||
modelId: string,
|
||||
sourceDirectory: string,
|
||||
externalSignal?: AbortSignal
|
||||
): Promise<InstalledSpeechModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
const source = resolve(sourceDirectory)
|
||||
const operation = this.beginOperation(entry.id, 'import', null)
|
||||
const detachExternalAbort = this.attachExternalSignal(
|
||||
externalSignal,
|
||||
operation.controller
|
||||
)
|
||||
let stagingDirectory: string | undefined
|
||||
try {
|
||||
await this.ensureRoot()
|
||||
await this.assertNotInstalled(entry.id)
|
||||
await this.validateLocalDirectory(
|
||||
source,
|
||||
entry,
|
||||
operation.controller.signal
|
||||
)
|
||||
stagingDirectory = await this.createStagingDirectory(entry.id)
|
||||
operation.progress.phase = 'transferring'
|
||||
for (const file of entry.files) {
|
||||
ensureNotAborted(operation.controller.signal)
|
||||
operation.progress.currentFile = file.name
|
||||
const sourceFile = safeChild(source, file.name)
|
||||
const destination = safeChild(stagingDirectory, file.name)
|
||||
await copyFile(sourceFile, destination)
|
||||
ensureNotAborted(operation.controller.signal)
|
||||
const copied = await stat(destination)
|
||||
if (copied.size > this.maxFileBytes) {
|
||||
throw new RangeError(`模型文件过大:${file.name}`)
|
||||
}
|
||||
operation.progress.completedBytes += copied.size
|
||||
}
|
||||
operation.progress.totalBytes =
|
||||
operation.progress.completedBytes
|
||||
operation.progress.phase = 'installing'
|
||||
operation.progress.currentFile = null
|
||||
const installed = await this.createInstalledManifest(
|
||||
entry,
|
||||
'local',
|
||||
stagingDirectory,
|
||||
operation.controller.signal
|
||||
)
|
||||
ensureNotAborted(operation.controller.signal)
|
||||
await rename(
|
||||
stagingDirectory,
|
||||
this.modelDirectory(entry.id)
|
||||
)
|
||||
stagingDirectory = undefined
|
||||
return installed
|
||||
} finally {
|
||||
detachExternalAbort()
|
||||
this.operations.delete(entry.id)
|
||||
if (stagingDirectory) {
|
||||
await rm(stagingDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoot(): Promise<void> {
|
||||
await mkdir(this.rootDirectory, { recursive: true })
|
||||
}
|
||||
|
||||
private modelDirectory(modelId: string): string {
|
||||
const parsedId = speechModelIdSchema.parse(modelId)
|
||||
return safeChild(this.rootDirectory, parsedId)
|
||||
}
|
||||
|
||||
private requireCatalogEntry(modelId: string): SpeechModelCatalogEntry {
|
||||
const parsedId = speechModelIdSchema.parse(modelId)
|
||||
const entry = this.catalog.find((candidate) => candidate.id === parsedId)
|
||||
if (!entry) {
|
||||
throw new Error('未知的语音模型')
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private beginOperation(
|
||||
modelId: string,
|
||||
kind: SpeechModelOperation['kind'],
|
||||
totalBytes: number | null
|
||||
): ActiveOperation {
|
||||
if (this.operations.has(modelId)) {
|
||||
throw new Error('该模型已有进行中的操作')
|
||||
}
|
||||
const operation: ActiveOperation = {
|
||||
controller: new AbortController(),
|
||||
progress: {
|
||||
modelId,
|
||||
kind,
|
||||
phase: 'preparing',
|
||||
currentFile: null,
|
||||
completedBytes: 0,
|
||||
totalBytes
|
||||
}
|
||||
}
|
||||
this.operations.set(modelId, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
private attachExternalSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
controller: AbortController
|
||||
): () => void {
|
||||
if (!signal) {
|
||||
return () => undefined
|
||||
}
|
||||
const abort = (): void => controller.abort()
|
||||
if (signal.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
return () => signal.removeEventListener('abort', abort)
|
||||
}
|
||||
|
||||
private async assertNotInstalled(modelId: string): Promise<void> {
|
||||
try {
|
||||
await lstat(this.modelDirectory(modelId))
|
||||
throw new Error('语音模型已安装')
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async createStagingDirectory(modelId: string): Promise<string> {
|
||||
const directory = safeChild(
|
||||
this.rootDirectory,
|
||||
`.install-${modelId}-${randomUUID()}`
|
||||
)
|
||||
await mkdir(directory, { recursive: false })
|
||||
return directory
|
||||
}
|
||||
|
||||
private async fetchFollowingRedirects(
|
||||
initialUrl: string,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
let url = validateDownloadUrl(initialUrl)
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
ensureNotAborted(signal)
|
||||
const response = await this.transport(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
signal
|
||||
})
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error('模型下载重定向次数过多')
|
||||
}
|
||||
const location = response.headers.get('location')
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
if (!location) {
|
||||
throw new Error('模型下载重定向缺少地址')
|
||||
}
|
||||
url = validateDownloadUrl(new URL(location, url).toString())
|
||||
continue
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFile(
|
||||
file: SpeechModelFileSpec & {
|
||||
download: NonNullable<SpeechModelFileSpec['download']>
|
||||
},
|
||||
destination: string,
|
||||
operation: ActiveOperation,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (
|
||||
file.download.size > this.maxFileBytes ||
|
||||
file.download.size <= 0
|
||||
) {
|
||||
throw new RangeError(`模型文件大小超出限制:${file.name}`)
|
||||
}
|
||||
const response = await this.fetchFollowingRedirects(
|
||||
file.download.url,
|
||||
signal
|
||||
)
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error(`模型下载失败:HTTP ${response.status}`)
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('模型下载响应没有内容')
|
||||
}
|
||||
const declaredLength = response.headers.get('content-length')
|
||||
if (declaredLength !== null) {
|
||||
const parsedLength = Number(declaredLength)
|
||||
if (
|
||||
!Number.isSafeInteger(parsedLength) ||
|
||||
parsedLength !== file.download.size
|
||||
) {
|
||||
await response.body.cancel().catch(() => undefined)
|
||||
throw new Error(`模型文件大小不匹配:${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const partialPath = `${destination}${PARTIAL_SUFFIX}`
|
||||
const handle = await open(partialPath, 'wx')
|
||||
const reader = response.body.getReader()
|
||||
const hash = createHash('sha256')
|
||||
let written = 0
|
||||
try {
|
||||
while (true) {
|
||||
ensureNotAborted(signal)
|
||||
const result = await reader.read()
|
||||
if (result.done) {
|
||||
break
|
||||
}
|
||||
written += result.value.byteLength
|
||||
if (
|
||||
written > file.download.size ||
|
||||
written > this.maxFileBytes
|
||||
) {
|
||||
await reader.cancel()
|
||||
throw new RangeError(`模型文件过大:${file.name}`)
|
||||
}
|
||||
await handle.write(result.value)
|
||||
hash.update(result.value)
|
||||
operation.progress.completedBytes += result.value.byteLength
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (written !== file.download.size) {
|
||||
throw new Error(`模型文件大小不匹配:${file.name}`)
|
||||
}
|
||||
if (hash.digest('hex') !== file.download.sha256) {
|
||||
throw new Error(`模型文件校验失败:${file.name}`)
|
||||
}
|
||||
await rename(partialPath, destination)
|
||||
}
|
||||
|
||||
private async validateLocalDirectory(
|
||||
sourceDirectory: string,
|
||||
entry: SpeechModelCatalogEntry,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const sourceInfo = await lstat(sourceDirectory)
|
||||
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
|
||||
throw new Error('本地模型来源必须是普通目录')
|
||||
}
|
||||
await this.rejectUnsafeLocalEntries(sourceDirectory, signal, {
|
||||
visited: 0
|
||||
})
|
||||
for (const expectedFile of entry.files) {
|
||||
ensureNotAborted(signal)
|
||||
const sourceFile = safeChild(sourceDirectory, expectedFile.name)
|
||||
const sourceFileInfo = await lstat(sourceFile)
|
||||
if (
|
||||
!sourceFileInfo.isFile() ||
|
||||
sourceFileInfo.isSymbolicLink()
|
||||
) {
|
||||
throw new Error(`模型文件必须是普通文件:${expectedFile.name}`)
|
||||
}
|
||||
if (
|
||||
sourceFileInfo.size <= 0 ||
|
||||
sourceFileInfo.size > this.maxFileBytes
|
||||
) {
|
||||
throw new RangeError(`模型文件大小无效:${expectedFile.name}`)
|
||||
}
|
||||
if (
|
||||
expectedFile.download &&
|
||||
(sourceFileInfo.size !== expectedFile.download.size ||
|
||||
(await hashFile(sourceFile, signal)).sha256 !==
|
||||
expectedFile.download.sha256)
|
||||
) {
|
||||
throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectUnsafeLocalEntries(
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
counter: { visited: number }
|
||||
): Promise<void> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
ensureNotAborted(signal)
|
||||
counter.visited += 1
|
||||
if (counter.visited > 4_096) {
|
||||
throw new Error('本地模型目录包含过多条目')
|
||||
}
|
||||
if (executableExtensionPattern.test(entry.name)) {
|
||||
throw new Error(`本地模型目录包含可执行文件:${entry.name}`)
|
||||
}
|
||||
const path = safeChild(directory, entry.name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new Error(`本地模型目录不能包含符号链接:${entry.name}`)
|
||||
}
|
||||
if (metadata.isDirectory()) {
|
||||
await this.rejectUnsafeLocalEntries(path, signal, counter)
|
||||
} else if (
|
||||
metadata.isFile() &&
|
||||
(((metadata.mode & 0o111) !== 0 &&
|
||||
process.platform !== 'win32') ||
|
||||
(await this.hasExecutableSignature(path)))
|
||||
) {
|
||||
throw new Error(`本地模型目录包含可执行文件:${entry.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async hasExecutableSignature(path: string): Promise<boolean> {
|
||||
const handle = await open(path, 'r')
|
||||
const header = Buffer.alloc(4)
|
||||
try {
|
||||
const { bytesRead } = await handle.read(header, 0, header.length, 0)
|
||||
if (bytesRead < 2) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
(header[0] === 0x4d && header[1] === 0x5a) ||
|
||||
(header[0] === 0x23 && header[1] === 0x21)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
bytesRead === 4 &&
|
||||
((header[0] === 0x7f &&
|
||||
header[1] === 0x45 &&
|
||||
header[2] === 0x4c &&
|
||||
header[3] === 0x46) ||
|
||||
[
|
||||
'cafebabe',
|
||||
'cefaedfe',
|
||||
'cffaedfe',
|
||||
'feedface',
|
||||
'feedfacf'
|
||||
].includes(header.toString('hex')))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
private async createInstalledManifest(
|
||||
entry: SpeechModelCatalogEntry,
|
||||
source: InstalledSpeechModel['source'],
|
||||
stagingDirectory: string,
|
||||
signal: AbortSignal
|
||||
): Promise<InstalledSpeechModel> {
|
||||
const files = []
|
||||
for (const file of entry.files) {
|
||||
ensureNotAborted(signal)
|
||||
const metadata = await hashFile(
|
||||
safeChild(stagingDirectory, file.name),
|
||||
signal
|
||||
)
|
||||
files.push({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
...metadata
|
||||
})
|
||||
}
|
||||
const manifest = installedSpeechModelSchema.parse({
|
||||
id: entry.id,
|
||||
displayName: entry.displayName,
|
||||
source,
|
||||
installedAt: new Date().toISOString(),
|
||||
files
|
||||
})
|
||||
await writeFile(
|
||||
safeChild(stagingDirectory, MANIFEST_FILE_NAME),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ encoding: 'utf8', flag: 'wx' }
|
||||
)
|
||||
return manifest
|
||||
}
|
||||
|
||||
private async readInstalled(): Promise<InstalledSpeechModel[]> {
|
||||
const entries = await readdir(this.rootDirectory, {
|
||||
withFileTypes: true
|
||||
})
|
||||
const installed: InstalledSpeechModel[] = []
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!entry.isDirectory() ||
|
||||
entry.name.startsWith('.install-') ||
|
||||
!speechModelIdSchema.safeParse(entry.name).success
|
||||
) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const manifestPath = safeChild(
|
||||
this.modelDirectory(entry.name),
|
||||
MANIFEST_FILE_NAME
|
||||
)
|
||||
const manifest = installedSpeechModelSchema.parse(
|
||||
JSON.parse(await readFile(manifestPath, 'utf8')) as unknown
|
||||
)
|
||||
if (manifest.id === entry.name) {
|
||||
installed.push(manifest)
|
||||
}
|
||||
} catch {
|
||||
// Incomplete or externally modified directories are not installed.
|
||||
}
|
||||
}
|
||||
return installed.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
private async readSelection(): Promise<string | null> {
|
||||
try {
|
||||
const value = selectionSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
safeChild(this.rootDirectory, SELECTION_FILE_NAME),
|
||||
'utf8'
|
||||
)
|
||||
) as unknown
|
||||
)
|
||||
return value.selectedModelId
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSelection(modelId: string | null): Promise<void> {
|
||||
await this.ensureRoot()
|
||||
const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME)
|
||||
const partial = safeChild(
|
||||
this.rootDirectory,
|
||||
`${SELECTION_FILE_NAME}.${randomUUID()}${PARTIAL_SUFFIX}`
|
||||
)
|
||||
await writeFile(
|
||||
partial,
|
||||
`${JSON.stringify(
|
||||
selectionSchema.parse({ selectedModelId: modelId })
|
||||
)}\n`,
|
||||
{ encoding: 'utf8', flag: 'wx' }
|
||||
)
|
||||
try {
|
||||
await rename(partial, target)
|
||||
} catch (error) {
|
||||
await rm(partial, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createSpeechModelManager(
|
||||
options: SpeechModelManagerOptions
|
||||
): SpeechModelManager {
|
||||
return new SpeechModelManager(options)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
SPEECH_TRANSCRIPTION_SAMPLE_RATE,
|
||||
type SpeechTranscriptionInput
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import {
|
||||
SpeechTranscriptionService,
|
||||
createSherpaRecognizerConfig
|
||||
} from './speech-transcription-service'
|
||||
import type { SelectedSpeechRuntimeModel } from './speech-model-manager'
|
||||
|
||||
const requestId = '00000000-0000-4000-8000-000000000001'
|
||||
|
||||
function whisperModel(): SelectedSpeechRuntimeModel {
|
||||
return {
|
||||
id: 'whisper-tiny-multilingual',
|
||||
family: 'whisper',
|
||||
directory: 'C:\\models\\whisper',
|
||||
files: [
|
||||
{
|
||||
name: 'tiny-encoder.int8.onnx',
|
||||
role: 'encoder',
|
||||
size: 1,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'tiny-decoder.int8.onnx',
|
||||
role: 'decoder',
|
||||
size: 1,
|
||||
sha256: 'b'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'tiny-tokens.txt',
|
||||
role: 'tokens',
|
||||
size: 1,
|
||||
sha256: 'c'.repeat(64)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function input(): SpeechTranscriptionInput {
|
||||
return {
|
||||
requestId,
|
||||
sampleRate: SPEECH_TRANSCRIPTION_SAMPLE_RATE,
|
||||
audio: new Float32Array([0, 0.25, -0.25]).buffer
|
||||
}
|
||||
}
|
||||
|
||||
describe('SpeechTranscriptionService', () => {
|
||||
it('wires the selected Whisper files to bounded local inference', async () => {
|
||||
const runner = vi.fn(async () => ' 本地识别结果 ')
|
||||
const service = new SpeechTranscriptionService(
|
||||
{
|
||||
getSelectedRuntimeModel: vi.fn(async () => whisperModel())
|
||||
},
|
||||
runner
|
||||
)
|
||||
|
||||
await expect(service.transcribe(input())).resolves.toEqual({
|
||||
text: '本地识别结果'
|
||||
})
|
||||
expect(runner).toHaveBeenCalledWith(
|
||||
createSherpaRecognizerConfig(whisperModel()),
|
||||
expect.any(Float32Array),
|
||||
SPEECH_TRANSCRIPTION_SAMPLE_RATE,
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(
|
||||
createSherpaRecognizerConfig(whisperModel()).modelConfig.whisper
|
||||
?.language
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('requires an installed selected model and rejects oversized audio', async () => {
|
||||
const service = new SpeechTranscriptionService(
|
||||
{
|
||||
getSelectedRuntimeModel: vi.fn(async () => undefined)
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
await expect(service.transcribe(input())).rejects.toThrow(
|
||||
'安装并选择本地语音模型'
|
||||
)
|
||||
await expect(
|
||||
service.transcribe({
|
||||
...input(),
|
||||
audio: new ArrayBuffer(
|
||||
SPEECH_TRANSCRIPTION_SAMPLE_RATE * 20 * 4 + 4
|
||||
)
|
||||
})
|
||||
).rejects.toThrow('录音数据')
|
||||
})
|
||||
|
||||
it('aborts active inference and cleans up cancellation state', async () => {
|
||||
const runner = vi.fn(
|
||||
(
|
||||
_config: unknown,
|
||||
_samples: Float32Array,
|
||||
_sampleRate: number,
|
||||
signal: AbortSignal
|
||||
) =>
|
||||
new Promise<string>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(new Error('cancelled by test')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
const service = new SpeechTranscriptionService(
|
||||
{
|
||||
getSelectedRuntimeModel: vi.fn(async () => whisperModel())
|
||||
},
|
||||
runner
|
||||
)
|
||||
|
||||
const transcription = service.transcribe(input())
|
||||
await vi.waitFor(() => expect(runner).toHaveBeenCalledOnce())
|
||||
expect(service.cancel(requestId)).toBe(true)
|
||||
await expect(transcription).rejects.toThrow('cancelled by test')
|
||||
expect(service.cancel(requestId)).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces inference failures and frees the request for retry', async () => {
|
||||
const runner = vi.fn(async () => {
|
||||
throw new Error('Runtime failed')
|
||||
})
|
||||
const service = new SpeechTranscriptionService(
|
||||
{
|
||||
getSelectedRuntimeModel: vi.fn(async () => whisperModel())
|
||||
},
|
||||
runner
|
||||
)
|
||||
|
||||
await expect(service.transcribe(input())).rejects.toThrow(
|
||||
'Runtime failed'
|
||||
)
|
||||
await expect(service.transcribe(input())).rejects.toThrow(
|
||||
'Runtime failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import {
|
||||
speechTranscriptionInputSchema,
|
||||
speechTranscriptionResultSchema,
|
||||
type SpeechTranscriptionInput,
|
||||
type SpeechTranscriptionResult
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import type {
|
||||
SelectedSpeechRuntimeModel,
|
||||
SpeechModelManager
|
||||
} from './speech-model-manager'
|
||||
|
||||
const TRANSCRIPTION_TIMEOUT_MS = 120_000
|
||||
|
||||
type SherpaRecognizerConfig = {
|
||||
featConfig: {
|
||||
sampleRate: number
|
||||
featureDim: number
|
||||
}
|
||||
modelConfig: {
|
||||
tokens: string
|
||||
numThreads: number
|
||||
debug: number
|
||||
provider: 'cpu'
|
||||
senseVoice?: {
|
||||
model: string
|
||||
language: string
|
||||
useInverseTextNormalization: number
|
||||
}
|
||||
whisper?: {
|
||||
encoder: string
|
||||
decoder: string
|
||||
language: string
|
||||
task: 'transcribe'
|
||||
tailPaddings: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechTranscriptionRunner = (
|
||||
config: SherpaRecognizerConfig,
|
||||
samples: Float32Array,
|
||||
sampleRate: number,
|
||||
signal: AbortSignal
|
||||
) => Promise<string>
|
||||
|
||||
type SpeechModelResolver = Pick<
|
||||
SpeechModelManager,
|
||||
'getSelectedRuntimeModel'
|
||||
>
|
||||
|
||||
const workerSource = String.raw`
|
||||
const { parentPort, workerData } = require('node:worker_threads')
|
||||
|
||||
let recognizer
|
||||
let stream
|
||||
try {
|
||||
const sherpa = require(workerData.sherpaModulePath)
|
||||
recognizer = sherpa.createOfflineRecognizer(workerData.config)
|
||||
stream = recognizer.createStream()
|
||||
stream.acceptWaveform(
|
||||
workerData.sampleRate,
|
||||
new Float32Array(workerData.samples)
|
||||
)
|
||||
recognizer.decode(stream)
|
||||
const result = recognizer.getResult(stream)
|
||||
parentPort.postMessage({
|
||||
ok: true,
|
||||
text: typeof result?.text === 'string' ? result.text : ''
|
||||
})
|
||||
} catch {
|
||||
parentPort.postMessage({ ok: false })
|
||||
} finally {
|
||||
stream?.free()
|
||||
recognizer?.free()
|
||||
}
|
||||
`
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error('语音识别已取消')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function requiredFile(
|
||||
model: SelectedSpeechRuntimeModel,
|
||||
role: SelectedSpeechRuntimeModel['files'][number]['role']
|
||||
): string {
|
||||
const file = model.files.find((candidate) => candidate.role === role)
|
||||
if (!file) {
|
||||
throw new Error('所选语音模型文件不完整,请重新安装模型')
|
||||
}
|
||||
return join(model.directory, file.name)
|
||||
}
|
||||
|
||||
export function createSherpaRecognizerConfig(
|
||||
model: SelectedSpeechRuntimeModel
|
||||
): SherpaRecognizerConfig {
|
||||
const tokens = requiredFile(model, 'tokens')
|
||||
const base = {
|
||||
featConfig: {
|
||||
sampleRate: 16_000,
|
||||
featureDim: 80
|
||||
},
|
||||
modelConfig: {
|
||||
tokens,
|
||||
numThreads: 2,
|
||||
debug: 0,
|
||||
provider: 'cpu' as const
|
||||
}
|
||||
}
|
||||
if (model.family === 'sensevoice') {
|
||||
return {
|
||||
...base,
|
||||
modelConfig: {
|
||||
...base.modelConfig,
|
||||
senseVoice: {
|
||||
model: requiredFile(model, 'model'),
|
||||
language: 'auto',
|
||||
useInverseTextNormalization: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
modelConfig: {
|
||||
...base.modelConfig,
|
||||
whisper: {
|
||||
encoder: requiredFile(model, 'encoder'),
|
||||
decoder: requiredFile(model, 'decoder'),
|
||||
language: '',
|
||||
task: 'transcribe',
|
||||
tailPaddings: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const runSherpaTranscription: SpeechTranscriptionRunner = (
|
||||
config,
|
||||
samples,
|
||||
sampleRate,
|
||||
signal
|
||||
) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(createAbortError())
|
||||
return
|
||||
}
|
||||
const audioBuffer = samples.buffer as ArrayBuffer
|
||||
const worker = new Worker(workerSource, {
|
||||
eval: true,
|
||||
workerData: {
|
||||
sherpaModulePath: require.resolve('sherpa-onnx'),
|
||||
config,
|
||||
sampleRate,
|
||||
samples: audioBuffer
|
||||
},
|
||||
transferList: [audioBuffer]
|
||||
})
|
||||
let settled = false
|
||||
const finish = (
|
||||
action: () => void,
|
||||
terminate = true
|
||||
): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener('abort', abort)
|
||||
worker.removeAllListeners()
|
||||
if (terminate) {
|
||||
void worker.terminate()
|
||||
}
|
||||
action()
|
||||
}
|
||||
const abort = (): void =>
|
||||
finish(() => reject(createAbortError()))
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
finish(() =>
|
||||
reject(new Error('本地语音识别超时,请缩短录音后重试'))
|
||||
),
|
||||
TRANSCRIPTION_TIMEOUT_MS
|
||||
)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
worker.once(
|
||||
'message',
|
||||
(message: { ok?: boolean; text?: unknown }) => {
|
||||
if (message.ok && typeof message.text === 'string') {
|
||||
const text = message.text
|
||||
finish(() => resolve(text), false)
|
||||
} else {
|
||||
finish(
|
||||
() =>
|
||||
reject(
|
||||
new Error('本地语音识别失败,请重新安装模型后重试')
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
worker.once('error', () =>
|
||||
finish(() =>
|
||||
reject(new Error('本地语音识别 Runtime 启动失败'))
|
||||
)
|
||||
)
|
||||
worker.once('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
finish(
|
||||
() =>
|
||||
reject(new Error('本地语音识别 Runtime 意外退出')),
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export class SpeechTranscriptionService {
|
||||
private readonly active = new Map<string, AbortController>()
|
||||
|
||||
constructor(
|
||||
private readonly models: SpeechModelResolver,
|
||||
private readonly runner: SpeechTranscriptionRunner =
|
||||
runSherpaTranscription
|
||||
) {}
|
||||
|
||||
async transcribe(input: unknown): Promise<SpeechTranscriptionResult> {
|
||||
const request = speechTranscriptionInputSchema.parse(input)
|
||||
if (this.active.has(request.requestId)) {
|
||||
throw new Error('该语音识别请求已在运行')
|
||||
}
|
||||
if (this.active.size > 0) {
|
||||
throw new Error('已有语音识别正在运行,请稍后重试')
|
||||
}
|
||||
const samples = new Float32Array(request.audio.slice(0))
|
||||
if (
|
||||
samples.some(
|
||||
(sample) =>
|
||||
!Number.isFinite(sample) || sample < -1 || sample > 1
|
||||
)
|
||||
) {
|
||||
throw new Error('录音采样数据无效')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
this.active.set(request.requestId, controller)
|
||||
try {
|
||||
const model = await this.models.getSelectedRuntimeModel()
|
||||
if (!model) {
|
||||
throw new Error('请先在设置中安装并选择本地语音模型')
|
||||
}
|
||||
const text = await this.runner(
|
||||
createSherpaRecognizerConfig(model),
|
||||
samples,
|
||||
request.sampleRate,
|
||||
controller.signal
|
||||
)
|
||||
return speechTranscriptionResultSchema.parse({ text })
|
||||
} finally {
|
||||
this.active.delete(request.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
cancel(requestId: SpeechTranscriptionInput['requestId']): boolean {
|
||||
const parsedId = speechTranscriptionInputSchema.shape.requestId.parse(
|
||||
requestId
|
||||
)
|
||||
const controller = this.active.get(parsedId)
|
||||
if (!controller) {
|
||||
return false
|
||||
}
|
||||
controller.abort()
|
||||
return true
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const controller of this.active.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
this.active.clear()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user