feat: 端侧转写通路(sherpa-onnx WASM + SenseVoiceSmall,音频不出设备)+ 模型获取脚本与接口校验
This commit is contained in:
@@ -10,3 +10,6 @@ VITE_JEV_PROXY_URL=
|
||||
# ASR(语音转写)代理地址。Tauri/不支持 SpeechRecognition 的环境走录音上传通路;
|
||||
# 服务端未配置真实 ASR provider 时返回占位结果,输入框兜底为打字。
|
||||
VITE_ASR_PROXY_URL=http://localhost:8787
|
||||
|
||||
# 端侧小模型转写(sherpa-onnx WASM + SenseVoiceSmall)默认开;'0' 关闭
|
||||
# VITE_ONDEVICE_ASR=0
|
||||
|
||||
@@ -31,5 +31,8 @@ dist-ssr
|
||||
# 生成物:评审包(pnpm script:review 重新生成)
|
||||
docs/script/review-bundle.md
|
||||
|
||||
# 端侧 ASR 资产(pnpm fetch:asr 重新下载,模型体积大不进 git)
|
||||
public/asr/
|
||||
|
||||
# 工具产物
|
||||
.codegraph/
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"verify": "tsx scripts/verify.ts",
|
||||
"fetch:asr": "tsx scripts/fetch-ondevice-asr.ts",
|
||||
"verify:live": "tsx scripts/verify-live.ts",
|
||||
"art:prompts": "tsx scripts/extract-art-prompts.ts",
|
||||
"script:review": "tsx scripts/build-script-review.ts"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 端侧 ASR 资产获取:pnpm fetch:asr
|
||||
*
|
||||
* 下载 sherpa-onnx WASM 运行时 + SenseVoiceSmall int8 模型,产物落到 public/asr/
|
||||
* (该目录在 .gitignore 里,模型不进 git;换机器/删了目录就重跑本脚本)。
|
||||
*
|
||||
* 资产来源(2026-10-04 核实,大小为当天下载实测字节数):
|
||||
* - WASM 运行时:HuggingFace 官方 space k2-fsa/web-assembly-vad-asr-sherpa-onnx-zh-zipformer-ctc
|
||||
* https://huggingface.co/spaces/k2-fsa/web-assembly-vad-asr-sherpa-onnx-zh-zipformer-ctc/resolve/main/<文件名>
|
||||
* (glue 95,308 B / wasm 11,722,172 B / api 47,391 B)
|
||||
* 注意:普通 wasm/asr 运行时(GitHub release 的 zipformer/paraformer demo)只导出
|
||||
* Online(流式)API,Offline 系列不可用;vad-asr 构建才导出 OfflineRecognizer
|
||||
* (wasm/vad-asr/CMakeLists.txt 的 exported_functions),而 SenseVoice 是离线模型,
|
||||
* 必须用这一份。文件逐个下载,无需解压 tarball。
|
||||
* - 模型:GitHub release asr-models
|
||||
* https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2
|
||||
* (163,002,883 B;内含 model.int8.onnx 239,233,841 B + tokens.txt 315,894 B)
|
||||
*
|
||||
* 关键改造:官方 glue(sherpa-onnx-wasm-main-vad-asr.js)内嵌的 Emscripten 打包清单指向
|
||||
* zipformer-ctc demo 的 silero_vad + zipformer 模型(367MB .data)。我们把清单改写为只含
|
||||
* /model.int8.onnx + /tokens.txt 两个文件,并据此生成自己的 .data(两文件直接拼接,
|
||||
* Emscripten preload 格式 = 原始字节流 + JS 侧清单)。这样运行时一次请求拿到模型包,
|
||||
* 模型出现在 MEMFS 根路径,OfflineRecognizer 按绝对路径读取。
|
||||
*
|
||||
* 产物(public/asr/):
|
||||
* sherpa-onnx-wasm-main-vad-asr.js glue(清单已改写)
|
||||
* sherpa-onnx-wasm-main-vad-asr.wasm 运行时 wasm(11,722,172 B,含 VAD + Offline API)
|
||||
* sherpa-onnx-wasm-main-vad-asr.data 模型预加载包(≈239,549,735 B)
|
||||
* sherpa-onnx-asr.js 官方 JS API 封装(OfflineRecognizer)
|
||||
* sense-voice-LICENSE.txt 模型许可(FunASR 模型许可,允许商用需署名)
|
||||
* manifest.json 资产清单 + 字节数(引擎下载进度与完整性自检用)
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OUT_DIR = path.join(ROOT, 'public', 'asr');
|
||||
const FORCE = process.argv.includes('--force');
|
||||
|
||||
const HF_SPACE =
|
||||
'https://huggingface.co/spaces/k2-fsa/web-assembly-vad-asr-sherpa-onnx-zh-zipformer-ctc/resolve/main';
|
||||
const GLUE_NAME = 'sherpa-onnx-wasm-main-vad-asr.js';
|
||||
const WASM_NAME = 'sherpa-onnx-wasm-main-vad-asr.wasm';
|
||||
const API_NAME = 'sherpa-onnx-asr.js';
|
||||
const DATA_NAME = 'sherpa-onnx-wasm-main-vad-asr.data';
|
||||
const RUNTIME_FILES: Array<{ name: string; bytes: number }> = [
|
||||
{ name: GLUE_NAME, bytes: 95_308 },
|
||||
{ name: WASM_NAME, bytes: 11_722_172 },
|
||||
{ name: API_NAME, bytes: 47_391 },
|
||||
];
|
||||
|
||||
const MODEL_URL =
|
||||
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2';
|
||||
const MODEL_BYTES = 163_002_883;
|
||||
// 模型 tarball 内
|
||||
const MODEL_ONNX_NAME = 'model.int8.onnx';
|
||||
const TOKENS_NAME = 'tokens.txt';
|
||||
const LICENSE_NAME = 'sense-voice-LICENSE.txt';
|
||||
|
||||
async function download(url: string, dest: string, expectedBytes: number): Promise<void> {
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
||||
const reader = res.body.getReader();
|
||||
const out = createWriteStream(dest);
|
||||
let received = 0;
|
||||
let logged = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
out.write(value);
|
||||
received += value.byteLength;
|
||||
if (received - logged >= 20 * 1024 * 1024) {
|
||||
logged = received;
|
||||
console.log(` … ${(received / 1e6).toFixed(0)} MB`);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => out.end(resolve as () => void, reject));
|
||||
if (received !== expectedBytes) {
|
||||
throw new Error(`大小不符:期望 ${expectedBytes},实际 ${received}(上游资产可能已更新,请核对后改脚本常量)`);
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
console.log(` 下载失败(第 ${attempt} 次):${err instanceof Error ? err.message : err}`);
|
||||
if (attempt === 3) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extract(tarball: string, dir: string): Promise<void> {
|
||||
const r = spawnSync('tar', ['-xf', tarball, '-C', dir], { stdio: 'inherit' });
|
||||
if (r.status !== 0) throw new Error(`解压失败:${tarball}`);
|
||||
}
|
||||
|
||||
/** 只含一个顶层目录时返回该目录路径,否则返回 dir 本身 */
|
||||
async function onlySubdir(dir: string): Promise<string> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory());
|
||||
return dirs.length === 1 && entries.length === 1 ? path.join(dir, dirs[0].name) : dir;
|
||||
}
|
||||
|
||||
/** 把 glue 里 loadPackage({...}) 的实参整体替换为新的打包清单(括号配平扫描,兼容 key 带不带引号两种生成风格) */
|
||||
export function patchGlueMetadata(glue: string, files: Array<{ filename: string; start: number; end: number }>, total: number): string {
|
||||
const anchor = 'loadPackage({';
|
||||
const start = glue.indexOf(anchor);
|
||||
if (start === -1) throw new Error('glue 中找不到 loadPackage 调用');
|
||||
let depth = 0;
|
||||
for (let i = start + 'loadPackage('.length; i < glue.length; i++) {
|
||||
const ch = glue[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const meta = `{files:[${files.map((f) => `{filename:"${f.filename}",start:${f.start},end:${f.end}}`).join(',')}],remote_package_size:${total}}`;
|
||||
return glue.slice(0, start) + 'loadPackage(' + meta + glue.slice(i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('glue 中 loadPackage 清单括号不配平');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const manifestPath = path.join(OUT_DIR, 'manifest.json');
|
||||
if (!FORCE && (await fs.stat(manifestPath).catch(() => null)) !== null) {
|
||||
console.log('public/asr/ 已存在(--force 可强制重新下载)');
|
||||
return;
|
||||
}
|
||||
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'jev-asr-'));
|
||||
console.log(`临时目录:${tmp}`);
|
||||
|
||||
console.log('\n1/2 下载 WASM 运行时(vad-asr 构建,含 Offline API)');
|
||||
for (const f of RUNTIME_FILES) {
|
||||
console.log(` ${HF_SPACE}/${f.name}`);
|
||||
await download(`${HF_SPACE}/${f.name}`, path.join(tmp, f.name), f.bytes);
|
||||
}
|
||||
|
||||
console.log(`\n2/2 下载 SenseVoiceSmall int8 模型(${(MODEL_BYTES / 1e6).toFixed(0)} MB)\n ${MODEL_URL}`);
|
||||
const modelTar = path.join(tmp, 'model.tar.bz2');
|
||||
await download(MODEL_URL, modelTar, MODEL_BYTES);
|
||||
const modelDir = path.join(tmp, 'model');
|
||||
await fs.mkdir(modelDir);
|
||||
await extract(modelTar, modelDir);
|
||||
const model = await onlySubdir(modelDir);
|
||||
|
||||
const glue = await fs.readFile(path.join(tmp, GLUE_NAME), 'utf8');
|
||||
const wasm = await fs.readFile(path.join(tmp, WASM_NAME));
|
||||
const api = await fs.readFile(path.join(tmp, API_NAME));
|
||||
const modelOnnx = await fs.readFile(path.join(model, MODEL_ONNX_NAME));
|
||||
const tokens = await fs.readFile(path.join(model, TOKENS_NAME));
|
||||
const licenseText = await fs.readFile(path.join(model, 'LICENSE')).catch(() => null);
|
||||
|
||||
// 生成 .data:model.int8.onnx 拼 tokens.txt;清单路径用绝对路径(MEMFS 根)
|
||||
const data = Buffer.concat([modelOnnx, tokens]);
|
||||
const patched = patchGlueMetadata(
|
||||
glue,
|
||||
[
|
||||
{ filename: `/${MODEL_ONNX_NAME}`, start: 0, end: modelOnnx.byteLength },
|
||||
{ filename: `/${TOKENS_NAME}`, start: modelOnnx.byteLength, end: data.byteLength },
|
||||
],
|
||||
data.byteLength,
|
||||
);
|
||||
if (patched === glue) throw new Error('清单改写失败');
|
||||
|
||||
const assets: Array<{ name: string; bytes: number }> = [
|
||||
{ name: GLUE_NAME, bytes: Buffer.byteLength(patched) },
|
||||
{ name: WASM_NAME, bytes: wasm.byteLength },
|
||||
{ name: DATA_NAME, bytes: data.byteLength },
|
||||
{ name: API_NAME, bytes: api.byteLength },
|
||||
];
|
||||
|
||||
await fs.writeFile(path.join(OUT_DIR, GLUE_NAME), patched);
|
||||
await fs.writeFile(path.join(OUT_DIR, WASM_NAME), wasm);
|
||||
await fs.writeFile(path.join(OUT_DIR, DATA_NAME), data);
|
||||
await fs.writeFile(path.join(OUT_DIR, API_NAME), api);
|
||||
if (licenseText) await fs.writeFile(path.join(OUT_DIR, LICENSE_NAME), licenseText);
|
||||
await fs.writeFile(
|
||||
path.join(OUT_DIR, 'manifest.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtime: { source: `${HF_SPACE} (k2-fsa 官方 space,vad-asr 构建)`, note: 'wasm/asr 构建不含 Offline API,SenseVoice 必须用 vad-asr 运行时' },
|
||||
model: { url: MODEL_URL, name: 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17' },
|
||||
assets,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// 自检:清单字节数与落盘文件一致
|
||||
for (const a of assets) {
|
||||
const actual = (await fs.stat(path.join(OUT_DIR, a.name))).size;
|
||||
if (actual !== a.bytes) throw new Error(`自检失败:${a.name} 大小 ${actual} != 清单 ${a.bytes}`);
|
||||
}
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
console.log(`\n完成。产物在 public/asr/(共 ${(assets.reduce((n, a) => n + a.bytes, 0) / 1e6).toFixed(1)} MB):`);
|
||||
for (const a of assets) console.log(` ${(a.bytes / 1e6).toFixed(2).padStart(8)} MB ${a.name}`);
|
||||
console.log('\n首次在浏览器/桌面端使用时懒加载到 CacheStorage;见 src/asr/ondevice.ts 头注释。');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 端侧小模型转写(sherpa-onnx WASM + SenseVoiceSmall)的 node 侧校验。
|
||||
*
|
||||
* WASM 实跑只能在浏览器/WKWebView 里做,这里不断言识别结果,只断言:
|
||||
* 1. 引擎接口形状:ready/init/transcribe 齐全;无浏览器能力时 init 优雅返回 false;
|
||||
* 2. mock 引擎跑通接口契约(verify 与真机共用同一形状);
|
||||
* 3. 长音频切分纯函数:≤28s、连续无重叠无遗漏、优先落静音谷;
|
||||
* 4. 重采样 48k→16k 的长度与插值正确;
|
||||
* 5. fetch 脚本的 glue 打包清单改写(括号配平,兼容带/不带引号两种生成风格)。
|
||||
* 运行:pnpm verify(由 verify.ts 末尾挂链引入)。
|
||||
*/
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { resampleTo16k } from '../src/asr/capture';
|
||||
import { SENSE_VOICE_MAX_SEGMENT_SEC, segmentForSenseVoice } from '../src/asr/segment';
|
||||
import { createMockOndeviceEngine, getOndeviceEngine, ondeviceAsrEnabled } from '../src/asr/ondevice';
|
||||
import { patchGlueMetadata } from './fetch-ondevice-asr';
|
||||
|
||||
let failures = 0;
|
||||
function check(ok: boolean, msg: string) {
|
||||
if (!ok) {
|
||||
failures++;
|
||||
console.error(`✗ ${msg}`);
|
||||
} else {
|
||||
console.log(`✓ ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const SR = 16000;
|
||||
|
||||
// --- 1. 引擎接口形状与优雅降级 ---
|
||||
{
|
||||
const engine = getOndeviceEngine();
|
||||
check(
|
||||
typeof engine.ready === 'function' && typeof engine.init === 'function' && typeof engine.transcribe === 'function',
|
||||
'端侧引擎接口形状:ready()/init()/transcribe() 齐全',
|
||||
);
|
||||
check(engine.ready() === false, '端侧引擎初始未就绪');
|
||||
check(ondeviceAsrEnabled() === true, '端侧引擎默认开启(VITE_ONDEVICE_ASR 未设)');
|
||||
const ok = await engine.init(() => undefined); // node 无 Worker/CacheStorage
|
||||
check(ok === false && engine.ready() === false, '无浏览器能力时 init 返回 false(静默降级,不抛错)');
|
||||
let threw = false;
|
||||
try {
|
||||
await engine.transcribe(new Float32Array(SR));
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, '未就绪时 transcribe 抛错(调用方捕获后走降级通路)');
|
||||
}
|
||||
|
||||
// --- 2. mock 引擎契约 ---
|
||||
{
|
||||
const mock = createMockOndeviceEngine({ text: '端侧 mock 转写' });
|
||||
check(mock.ready() === true, 'mock 引擎 ready');
|
||||
check((await mock.init()) === true, 'mock 引擎 init');
|
||||
const t = await mock.transcribe(new Float32Array(SR));
|
||||
check(t.text === '端侧 mock 转写', 'mock 引擎 transcribe 返回文本');
|
||||
const tagged = createMockOndeviceEngine({ text: 'x', emotion: '<|HAPPY|>', event: '<|BGM|>', lang: '<|zh|>' });
|
||||
const t2 = await tagged.transcribe(new Float32Array(0));
|
||||
check(t2.emotion === '<|HAPPY|>' && t2.event === '<|BGM|>' && t2.lang === '<|zh|>', '情感/事件/语种标签可透传(SenseVoice 经 sherpa-onnx 暴露)');
|
||||
}
|
||||
|
||||
// --- 3. 长音频切分 ---
|
||||
function sine(len: number, freq = 440): Float32Array {
|
||||
const a = new Float32Array(len);
|
||||
for (let i = 0; i < len; i++) a[i] = Math.sin((2 * Math.PI * freq * i) / SR);
|
||||
return a;
|
||||
}
|
||||
|
||||
function concat(segs: Float32Array[]): Float32Array {
|
||||
const n = segs.reduce((s, x) => s + x.length, 0);
|
||||
const out = new Float32Array(n);
|
||||
let o = 0;
|
||||
for (const s of segs) {
|
||||
out.set(s, o);
|
||||
o += s.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
{
|
||||
const short = sine(10 * SR);
|
||||
const segs = segmentForSenseVoice(short);
|
||||
check(segs.length === 1 && segs[0].length === short.length, '10 秒音频不切分(单段原样)');
|
||||
check(segmentForSenseVoice(new Float32Array(0)).length === 0, '空音频返回空数组');
|
||||
|
||||
// 60 秒"讲述":每说 9 秒停 1.5 秒(口语停顿节奏),切点都应落在停顿带里
|
||||
const SPEECH = 9 * SR;
|
||||
const PAUSE = 1.5 * SR;
|
||||
const pcm = new Float32Array(60 * SR);
|
||||
let cursor = 0;
|
||||
let utterance = 0;
|
||||
while (cursor < pcm.length) {
|
||||
const n = Math.min(SPEECH, pcm.length - cursor);
|
||||
pcm.set(sine(n, 440 + utterance * 100), cursor); // 不同音高,避免能量巧合
|
||||
cursor += n;
|
||||
utterance++;
|
||||
if (cursor < pcm.length) cursor = Math.min(cursor + PAUSE, pcm.length);
|
||||
}
|
||||
const segs2 = segmentForSenseVoice(pcm);
|
||||
const maxLen = SENSE_VOICE_MAX_SEGMENT_SEC * SR;
|
||||
check(segs2.length >= 2, `60 秒音频切为 ${segs2.length} 段(≥2)`);
|
||||
check(segs2.every((s) => s.length <= maxLen), `每段 ≤ ${SENSE_VOICE_MAX_SEGMENT_SEC}s`);
|
||||
check(concat(segs2).length === pcm.length, '分段拼接后长度等于原音频(无遗漏无重叠)');
|
||||
let silentCut = true;
|
||||
let offset = 0;
|
||||
for (const s of segs2.slice(0, -1)) {
|
||||
offset += s.length;
|
||||
// 切点应在停顿带内:向后 200ms 必须仍是静音(切点取停顿起始帧)
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let i = offset; i < Math.min(pcm.length, offset + 0.2 * SR); i++) {
|
||||
sum += pcm[i] * pcm[i];
|
||||
n++;
|
||||
}
|
||||
if (n > 0 && Math.sqrt(sum / n) > 0.01) silentCut = false;
|
||||
}
|
||||
check(silentCut, '切点落在静音谷(不是词中间)');
|
||||
|
||||
// 无静音可找时硬切,仍满足长度与覆盖约束
|
||||
const solid = sine(90 * SR);
|
||||
const segs3 = segmentForSenseVoice(solid);
|
||||
check(segs3.length >= 4 && segs3.every((s) => s.length <= maxLen) && concat(segs3).length === solid.length, '连续无静音音频硬切:段数/长度/覆盖全部满足');
|
||||
}
|
||||
|
||||
// --- 4. 重采样 ---
|
||||
{
|
||||
const from = new Float32Array(48000);
|
||||
for (let i = 0; i < from.length; i++) from[i] = i / 48000; // 线性 ramp,插值可精确验证
|
||||
const out = resampleTo16k(from, 48000);
|
||||
check(out.length === 16000, '48k→16k 长度比例正确');
|
||||
check(Math.abs(out[100] - 100 * 3 / 48000) < 1e-6, '线性插值数值正确');
|
||||
const same = new Float32Array([0.1, 0.2]);
|
||||
check(resampleTo16k(same, 16000) === same, '已是 16k 时原样返回(零拷贝)');
|
||||
}
|
||||
|
||||
// --- 5. glue 打包清单改写 ---
|
||||
{
|
||||
// v1.13.x glue 风格:key 不带引号
|
||||
const glueV1 = 'aa loadPackage({files:[{filename:"/.gitignore",start:0,end:0},{filename:"/encoder.onnx",start:0,end:195}],remote_package_size:195}); bb';
|
||||
// 旧 HF space glue 风格:key 带引号
|
||||
const glueV2 = 'aa loadPackage({"files":[{"filename":"/a","start":0,"end":1}],"remote_package_size":1})(); bb';
|
||||
const files = [
|
||||
{ filename: '/model.int8.onnx', start: 0, end: 100 },
|
||||
{ filename: '/tokens.txt', start: 100, end: 120 },
|
||||
];
|
||||
for (const [name, src] of [['无引号', glueV1], ['带引号', glueV2]] as const) {
|
||||
const out = patchGlueMetadata(src, files, 120);
|
||||
check(out.includes('/model.int8.onnx') && out.includes('/tokens.txt') && out.includes('remote_package_size:120'), `glue 清单改写(${name} 风格):文件名与总大小替换成功`);
|
||||
check(out.includes('/encoder.onnx') === false && out.includes('"/a"') === false, `glue 清单改写(${name} 风格):旧清单清除`);
|
||||
// 括号配平:loadPackage( 后能找到配平的收尾
|
||||
const tail = out.slice(out.indexOf('loadPackage(') + 'loadPackage('.length);
|
||||
let depth = 0;
|
||||
let balanced = false;
|
||||
for (const ch of tail) {
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
balanced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
check(balanced, `glue 清单改写(${name} 风格):替换后括号配平`);
|
||||
}
|
||||
assert.throws(() => patchGlueMetadata('no anchor here', [], 0), /loadPackage/, '无锚点时抛错(上游 glue 结构变了会及时发现)');
|
||||
console.log('✓ glue 清单改写:无锚点时显式抛错');
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} 项端侧 ASR 校验失败`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n端侧 ASR 接口形状校验通过(WASM 实跑需真机验证,见 README 已知缺口)');
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 16kHz 单声道 PCM 采集器(端侧转写专用)。
|
||||
*
|
||||
* AudioContext({sampleRate: 16000}) 只是采样率提示——Safari/WKWebView 经常忽略,
|
||||
* 实际可能是 48kHz,所以统一按 context 实际采样率收原始 Float32,stop 时再线性
|
||||
* 重采样到 16k(与官方 wasm demo 的 downsampleBuffer 同款均值/线性思路,ASR 够用)。
|
||||
*
|
||||
* 优先 AudioWorklet(不连 destination 会被部分浏览器挂起处理,这里经零增益
|
||||
* 节点接到 destination,标准做法);不支持时退 ScriptProcessor。
|
||||
* 只产出 Float32Array([-1,1]),不落地任何音频文件。
|
||||
*/
|
||||
|
||||
export interface Pcm16kRecorder {
|
||||
start(): Promise<void>;
|
||||
/** 停止采集并返回整段 16kHz 单声道 PCM;未在录音时抛错 */
|
||||
stop(): Float32Array;
|
||||
/** 放弃已采集数据(权限失败/用户取消时的清理) */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
export const TARGET_SAMPLE_RATE = 16000;
|
||||
|
||||
// 8 行 plain JS,用 Blob 喂给 addModule,绕开打包器对 AudioWorklet entry 的差异处理
|
||||
const WORKLET_SOURCE = `
|
||||
class PcmCaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const ch = inputs[0] && inputs[0][0];
|
||||
if (ch && ch.length > 0) this.port.postMessage(ch.slice());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('pcm-capture', PcmCaptureProcessor);
|
||||
`;
|
||||
|
||||
/** 线性插值重采样(单声道,目标 16kHz) */
|
||||
export function resampleTo16k(pcm: Float32Array, fromRate: number): Float32Array {
|
||||
if (fromRate === TARGET_SAMPLE_RATE || pcm.length === 0) return pcm;
|
||||
const ratio = fromRate / TARGET_SAMPLE_RATE;
|
||||
const outLen = Math.floor(pcm.length / ratio);
|
||||
const out = new Float32Array(outLen);
|
||||
for (let i = 0; i < outLen; i++) {
|
||||
const pos = i * ratio;
|
||||
const i0 = Math.floor(pos);
|
||||
const i1 = Math.min(i0 + 1, pcm.length - 1);
|
||||
const frac = pos - i0;
|
||||
out[i] = pcm[i0] * (1 - frac) + pcm[i1] * frac;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ActiveRecording {
|
||||
ctx: AudioContext;
|
||||
stream: MediaStream;
|
||||
nativeRate: number;
|
||||
chunks: Float32Array[];
|
||||
onChunk(chunk: Float32Array): void;
|
||||
teardown(): void;
|
||||
}
|
||||
|
||||
let active: ActiveRecording | null = null;
|
||||
|
||||
function collect(rec: ActiveRecording, chunk: Float32Array): void {
|
||||
// AudioBuffer 的底层数组会被复用,必须拷贝
|
||||
rec.chunks.push(chunk.slice());
|
||||
}
|
||||
|
||||
export function createPcm16kRecorder(): Pcm16kRecorder {
|
||||
return {
|
||||
async start() {
|
||||
if (active) throw new Error('已有进行中的录音');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
});
|
||||
const ctx = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
|
||||
await ctx.resume();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const rec: ActiveRecording = {
|
||||
ctx,
|
||||
stream,
|
||||
nativeRate: ctx.sampleRate,
|
||||
chunks: [],
|
||||
onChunk: () => undefined,
|
||||
teardown: () => undefined,
|
||||
};
|
||||
rec.onChunk = (c) => collect(rec, c);
|
||||
|
||||
let disconnected = false;
|
||||
const disconnect = () => {
|
||||
if (disconnected) return;
|
||||
disconnected = true;
|
||||
try {
|
||||
source.disconnect();
|
||||
} catch {
|
||||
// 节点可能已被浏览器回收
|
||||
}
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
void ctx.close();
|
||||
};
|
||||
rec.teardown = disconnect;
|
||||
|
||||
if (ctx.audioWorklet) {
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(
|
||||
URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'text/javascript' })),
|
||||
);
|
||||
const node = new AudioWorkletNode(ctx, 'pcm-capture');
|
||||
node.port.onmessage = (e: MessageEvent<Float32Array>) => rec.onChunk(e.data);
|
||||
const sink = ctx.createGain();
|
||||
sink.gain.value = 0; // 不监听回放,只防止处理链被挂起
|
||||
source.connect(node).connect(sink).connect(ctx.destination);
|
||||
active = rec;
|
||||
return;
|
||||
} catch {
|
||||
// addModule/WorkletNode 失败 → ScriptProcessor 兜底
|
||||
}
|
||||
}
|
||||
|
||||
const bufferSize = 4096;
|
||||
const legacy = ctx as AudioContext & {
|
||||
createScriptProcessor?: (buf: number, inp: number, out: number) => ScriptProcessorNode;
|
||||
createJavaScriptNode?: (buf: number, inp: number, out: number) => ScriptProcessorNode;
|
||||
};
|
||||
const node =
|
||||
legacy.createScriptProcessor?.(bufferSize, 1, 1) ?? legacy.createJavaScriptNode?.(bufferSize, 1, 1);
|
||||
if (!node) {
|
||||
disconnect();
|
||||
throw new Error('当前环境不支持音频采集');
|
||||
}
|
||||
node.onaudioprocess = (e: AudioProcessingEvent) => {
|
||||
const ch = e.inputBuffer.getChannelData(0);
|
||||
if (ch.length > 0) rec.onChunk(ch);
|
||||
};
|
||||
const sink = ctx.createGain();
|
||||
sink.gain.value = 0;
|
||||
source.connect(node).connect(sink).connect(ctx.destination);
|
||||
active = rec;
|
||||
},
|
||||
|
||||
stop() {
|
||||
const rec = active;
|
||||
if (!rec) throw new Error('未在录音');
|
||||
active = null;
|
||||
rec.teardown();
|
||||
const n = rec.chunks.reduce((s, c) => s + c.length, 0);
|
||||
const flat = new Float32Array(n);
|
||||
let offset = 0;
|
||||
for (const c of rec.chunks) {
|
||||
flat.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
rec.chunks = [];
|
||||
return resampleTo16k(flat, rec.nativeRate);
|
||||
},
|
||||
|
||||
abort() {
|
||||
const rec = active;
|
||||
if (!rec) return;
|
||||
active = null;
|
||||
rec.chunks = [];
|
||||
rec.teardown();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 端侧识别 Web Worker(classic worker):在后台线程加载 sherpa-onnx WASM 运行时,
|
||||
* 主线程通过 postMessage 送 16kHz PCM,这里用 OfflineRecognizer 整段解码。
|
||||
*
|
||||
* 资产传递方式:主线程已把 glue/api JS 文本与 .wasm/.data 二进制 fetch 好,
|
||||
* Worker 里用 Blob URL 喂给 Emscripten:
|
||||
* - glue(sherpa-onnx-wasm-main-vad-asr.js)在加载时立即拉取 .data 并解包到 MEMFS
|
||||
* (.data 由 fetch 脚本生成 = model.int8.onnx 拼 tokens.txt,glue 内嵌打包清单
|
||||
* 已在下载时被改写为对应这两个文件);
|
||||
* - Module.locateFile 把 .wasm/.data 指向 Blob URL,避免 Worker 内二次网络请求;
|
||||
* - 模型因此出现在 MEMFS 的 /model.int8.onnx 与 /tokens.txt,识别器直接按路径读。
|
||||
*
|
||||
* 注意保持 classic worker(Vite 默认格式):importScripts 需要它。
|
||||
*/
|
||||
import { segmentForSenseVoice } from './segment';
|
||||
import type { OndeviceTranscript } from './ondevice';
|
||||
|
||||
type WorkerInMessage =
|
||||
| { type: 'init'; glueJs: string; apiJs: string; wasm: ArrayBuffer; data: ArrayBuffer }
|
||||
| { type: 'transcribe'; id: number; pcm: Float32Array };
|
||||
|
||||
type WorkerOutMessage =
|
||||
| { type: 'ready' }
|
||||
| { type: 'error'; stage: string; message: string }
|
||||
| { type: 'result'; id: number } & OndeviceTranscript;
|
||||
|
||||
declare function importScripts(...urls: string[]): void;
|
||||
|
||||
interface SherpaStream {
|
||||
acceptWaveform(sampleRate: number, samples: Float32Array): void;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
interface SherpaResult {
|
||||
text?: string;
|
||||
lang?: string;
|
||||
emotion?: string;
|
||||
event?: string;
|
||||
}
|
||||
|
||||
interface SherpaOfflineRecognizer {
|
||||
createStream(): SherpaStream;
|
||||
decode(stream: SherpaStream): void;
|
||||
getResult(stream: SherpaStream): SherpaResult;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
declare const OfflineRecognizer: new (config: unknown, module: unknown) => SherpaOfflineRecognizer;
|
||||
|
||||
/** Emscripten Module 的全局形状(只声明我们用到的字段) */
|
||||
interface SherpaModuleGlobal {
|
||||
locateFile?(path: string, prefix: string): string;
|
||||
onRuntimeInitialized?(): void;
|
||||
print?(msg: string): void;
|
||||
printErr?(msg: string): void;
|
||||
}
|
||||
|
||||
const scope = self as unknown as {
|
||||
Module?: SherpaModuleGlobal;
|
||||
onmessage: ((e: MessageEvent<WorkerInMessage>) => void) | null;
|
||||
postMessage(msg: WorkerOutMessage): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
function fail(stage: string, err: unknown): void {
|
||||
scope.postMessage({ type: 'error', stage, message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
let recognizer: SherpaOfflineRecognizer | null = null;
|
||||
|
||||
function init(msg: Extract<WorkerInMessage, { type: 'init' }>): Promise<void> {
|
||||
const wasmUrl = URL.createObjectURL(new Blob([msg.wasm], { type: 'application/wasm' }));
|
||||
const dataUrl = URL.createObjectURL(new Blob([msg.data], { type: 'application/octet-stream' }));
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
scope.Module = {
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) return wasmUrl;
|
||||
if (path.endsWith('.data')) return dataUrl;
|
||||
return path;
|
||||
},
|
||||
onRuntimeInitialized: () => resolve(),
|
||||
print: () => undefined, // 官方 glue 很吵,静默
|
||||
printErr: () => undefined,
|
||||
};
|
||||
// 180 秒还没初始化成功(解包 240MB + 编译 wasm)就放弃
|
||||
setTimeout(() => reject(new Error('WASM 运行时初始化超时')), 180_000);
|
||||
});
|
||||
|
||||
const glueUrl = URL.createObjectURL(new Blob([msg.glueJs], { type: 'text/javascript' }));
|
||||
const apiUrl = URL.createObjectURL(new Blob([msg.apiJs], { type: 'text/javascript' }));
|
||||
importScripts(glueUrl, apiUrl);
|
||||
|
||||
return ready.then(() => {
|
||||
const config = {
|
||||
featConfig: { sampleRate: 16000, featureDim: 80 },
|
||||
modelConfig: {
|
||||
senseVoice: {
|
||||
model: '/model.int8.onnx',
|
||||
language: 'auto',
|
||||
useInverseTextNormalization: 1,
|
||||
},
|
||||
tokens: '/tokens.txt',
|
||||
numThreads: 1,
|
||||
provider: 'cpu',
|
||||
debug: 0,
|
||||
},
|
||||
decodingMethod: 'greedy_search',
|
||||
};
|
||||
recognizer = new OfflineRecognizer(config, scope.Module);
|
||||
});
|
||||
}
|
||||
|
||||
function transcribe(id: number, pcm: Float32Array): void {
|
||||
if (!recognizer) throw new Error('识别器未初始化');
|
||||
const segments = segmentForSenseVoice(pcm);
|
||||
const texts: string[] = [];
|
||||
const votes = { lang: new Map<string, number>(), emotion: new Map<string, number>(), event: new Map<string, number>() };
|
||||
|
||||
for (const seg of segments) {
|
||||
const stream = recognizer.createStream();
|
||||
try {
|
||||
stream.acceptWaveform(16000, seg);
|
||||
recognizer.decode(stream);
|
||||
const r = recognizer.getResult(stream);
|
||||
texts.push(r.text ?? '');
|
||||
for (const key of ['lang', 'emotion', 'event'] as const) {
|
||||
const value = r[key];
|
||||
if (value) votes[key].set(value, (votes[key].get(value) ?? 0) + 1);
|
||||
}
|
||||
} finally {
|
||||
stream.free();
|
||||
}
|
||||
}
|
||||
|
||||
const pick = (m: Map<string, number>): string | undefined =>
|
||||
[...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
|
||||
scope.postMessage({
|
||||
type: 'result',
|
||||
id,
|
||||
text: texts.join(''),
|
||||
lang: pick(votes.lang),
|
||||
emotion: pick(votes.emotion),
|
||||
event: pick(votes.event),
|
||||
});
|
||||
}
|
||||
|
||||
scope.onmessage = (e) => {
|
||||
const msg = e.data;
|
||||
if (msg.type === 'init') {
|
||||
init(msg).then(
|
||||
() => scope.postMessage({ type: 'ready' }),
|
||||
(err) => {
|
||||
fail('init', err);
|
||||
scope.close();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
transcribe(msg.id, msg.pcm);
|
||||
} catch (err) {
|
||||
fail('transcribe', err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 端侧小模型转写引擎(ADR-0004 最强"即焚":音频不出设备,只留转写文本)。
|
||||
*
|
||||
* 选型:sherpa-onnx WASM + SenseVoiceSmall int8(docs/research/asr-options.md 1.2 / 4.1 / 4.4)。
|
||||
* 运行时在 Web Worker 里加载(避免阻塞 UI),主线程只负责把 16kHz 单声道 PCM
|
||||
* 整体送进去解码——SenseVoice 是离线(Offline)识别器,录完一次性转写。
|
||||
*
|
||||
* 资产(`public/asr/`,由 `pnpm fetch:asr` 下载,不进 git):
|
||||
* - sherpa-onnx-wasm-main-vad-asr.js / .wasm:官方 WASM 运行时,取自 k2-fsa 的
|
||||
* vad-asr space。注意必须是 vad-asr 构建:普通 wasm/asr 运行时只导出 Online
|
||||
* (流式)API,而 SenseVoice 是离线模型,Offline 系列(CreateOfflineRecognizer
|
||||
* 等)只有 vad-asr 构建导出(见 wasm/vad-asr/CMakeLists.txt 的 exported_functions)。
|
||||
* glue 的打包清单已在下载时被改写为只含 model.int8.onnx + tokens.txt 两个文件;
|
||||
* - sherpa-onnx-wasm-main-vad-asr.data:Emscripten 预加载包 = model.int8.onnx 拼 tokens.txt;
|
||||
* - sherpa-onnx-asr.js:官方 JS API 封装(OfflineRecognizer);
|
||||
* - manifest.json:文件名 + 字节数,用于下载进度统计与完整性自检。
|
||||
*
|
||||
* 情感/事件标签:SenseVoice 经由 sherpa-onnx 的离线结果 JSON 暴露 lang / emotion /
|
||||
* event 字段(sherpa-onnx csrc/offline-recognizer-sense-voice-impl.h),引擎原样透传,
|
||||
* 不自行编造。VoiceInput 第一版只回填文本,标签留给讲述事件接线。
|
||||
*
|
||||
* 用 `VITE_ONDEVICE_ASR=0` 关闭(默认开)。任何一步失败(资产缺失、Worker 挂了、
|
||||
* 浏览器不支持)都表现为 init() 返回 false,调用方静默退回录音上传通路。
|
||||
*/
|
||||
|
||||
import { segmentForSenseVoice } from './segment';
|
||||
|
||||
export interface OndeviceTranscript {
|
||||
text: string;
|
||||
/** SenseVoice 输出,形如 "<|zh|>" / "<|HAPPY|>" / "<|BGM|>",无则缺省 */
|
||||
lang?: string;
|
||||
emotion?: string;
|
||||
event?: string;
|
||||
}
|
||||
|
||||
export interface OndeviceAsrEngine {
|
||||
/** 模型与运行时是否已就绪(init 成功过后恒为 true) */
|
||||
ready(): boolean;
|
||||
/** 懒加载资产(fetch → CacheStorage,带进度)并启动 Worker 里的 WASM 运行时 */
|
||||
init(onProgress?: (loaded: number, total: number) => void): Promise<boolean>;
|
||||
/** 整段 16kHz 单声道 PCM([-1,1] Float32)→ 本地转写文本 */
|
||||
transcribe(pcm16k: Float32Array): Promise<OndeviceTranscript>;
|
||||
}
|
||||
|
||||
/** public/asr/ 下的资产清单(与 scripts/fetch-ondevice-asr.ts 产出的 manifest 对应) */
|
||||
// Vite 注入 import.meta.env;node(verify)下不存在,读法要兼容两种环境
|
||||
const viteEnv = (import.meta.env as unknown as { BASE_URL?: string; VITE_ONDEVICE_ASR?: string } | undefined) ?? {};
|
||||
const ASSET_BASE = `${viteEnv.BASE_URL ?? '/'}asr/`;
|
||||
const CACHE_NAME = 'jev-ondevice-asr-v1';
|
||||
|
||||
interface AssetEntry {
|
||||
name: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
generatedAt: string;
|
||||
assets: AssetEntry[];
|
||||
}
|
||||
|
||||
// Worker 协议(与 ondevice-asr.worker.ts 对应)
|
||||
type WorkerInMessage =
|
||||
| { type: 'init'; glueJs: string; apiJs: string; wasm: ArrayBuffer; data: ArrayBuffer }
|
||||
| { type: 'transcribe'; id: number; pcm: Float32Array };
|
||||
|
||||
type WorkerOutMessage =
|
||||
| { type: 'ready' }
|
||||
| { type: 'error'; stage: string; message: string }
|
||||
| { type: 'result'; id: number; text: string; lang?: string; emotion?: string; event?: string };
|
||||
|
||||
export function ondeviceAsrEnabled(): boolean {
|
||||
return viteEnv.VITE_ONDEVICE_ASR !== '0';
|
||||
}
|
||||
|
||||
// 最近一次懒加载进度(组件可能多次挂载,让后来的 VoiceInput 直接看到)
|
||||
let lastLoadProgress: { loaded: number; total: number } | null = null;
|
||||
|
||||
export function getOndeviceLoadProgress(): { loaded: number; total: number } | null {
|
||||
return lastLoadProgress;
|
||||
}
|
||||
|
||||
function supportsOndeviceAsr(): boolean {
|
||||
return (
|
||||
typeof Worker !== 'undefined' &&
|
||||
typeof fetch !== 'undefined' &&
|
||||
typeof Blob !== 'undefined' &&
|
||||
typeof URL !== 'undefined' &&
|
||||
typeof URL.createObjectURL === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/** fetch(带下载进度)→ CacheStorage;缓存命中则不重复下载。缓存不可用时退化为普通 fetch。 */
|
||||
async function fetchAsset(
|
||||
entry: AssetEntry,
|
||||
cache: Cache | null,
|
||||
onBytes: (loaded: number) => void,
|
||||
): Promise<ArrayBuffer> {
|
||||
const url = ASSET_BASE + entry.name;
|
||||
if (cache) {
|
||||
const hit = await cache.match(url);
|
||||
if (hit) {
|
||||
const buf = await hit.arrayBuffer();
|
||||
onBytes(buf.byteLength);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok || !res.body) throw new Error(`下载端侧识别资产失败:${entry.name} (${res.status})`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.byteLength;
|
||||
onBytes(value.byteLength);
|
||||
}
|
||||
const buf = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
buf.set(c, offset);
|
||||
offset += c.byteLength;
|
||||
}
|
||||
if (cache && received === entry.bytes) {
|
||||
// 只缓存完整文件;Content-Type 不影响 ArrayBuffer 消费
|
||||
await cache.put(url, new Response(buf.slice().buffer, { headers: { 'Content-Type': 'application/octet-stream' } }));
|
||||
}
|
||||
return buf.buffer;
|
||||
}
|
||||
|
||||
class OndeviceAsrEngineImpl implements OndeviceAsrEngine {
|
||||
private worker: Worker | null = null;
|
||||
private isReady = false;
|
||||
private initPromise: Promise<boolean> | null = null;
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<
|
||||
number,
|
||||
{ resolve: (t: OndeviceTranscript) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
|
||||
>();
|
||||
|
||||
ready(): boolean {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
init(onProgress?: (loaded: number, total: number) => void): Promise<boolean> {
|
||||
this.initPromise ??= this.doInit(onProgress).catch(() => false);
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInit(onProgress?: (loaded: number, total: number) => void): Promise<boolean> {
|
||||
if (!ondeviceAsrEnabled() || !supportsOndeviceAsr()) return false;
|
||||
|
||||
let manifest: Manifest;
|
||||
try {
|
||||
const res = await fetch(ASSET_BASE + 'manifest.json');
|
||||
if (!res.ok) return false; // 资产未获取(未跑 pnpm fetch:asr),静默降级
|
||||
manifest = (await res.json()) as Manifest;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cache = typeof caches !== 'undefined' ? await caches.open(CACHE_NAME).catch(() => null) : null;
|
||||
const total = manifest.assets.reduce((n, a) => n + a.bytes, 0);
|
||||
let loaded = 0;
|
||||
const report = () => {
|
||||
lastLoadProgress = { loaded, total };
|
||||
onProgress?.(loaded, total);
|
||||
};
|
||||
|
||||
const buffers = new Map<string, ArrayBuffer>();
|
||||
try {
|
||||
for (const entry of manifest.assets) {
|
||||
const before = loaded;
|
||||
buffers.set(entry.name, await fetchAsset(entry, cache, (n) => {
|
||||
loaded = before + n;
|
||||
report();
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
return false; // 下载失败(网络/磁盘),降级到录音上传通路
|
||||
}
|
||||
|
||||
const glueJs = new TextDecoder().decode(buffers.get('sherpa-onnx-wasm-main-vad-asr.js'));
|
||||
const apiJs = new TextDecoder().decode(buffers.get('sherpa-onnx-asr.js'));
|
||||
const wasm = buffers.get('sherpa-onnx-wasm-main-vad-asr.wasm');
|
||||
const data = buffers.get('sherpa-onnx-wasm-main-vad-asr.data');
|
||||
if (!glueJs || !apiJs || !wasm || !data) return false;
|
||||
|
||||
const worker = new Worker(new URL('./ondevice-asr.worker.ts', import.meta.url));
|
||||
this.worker = worker;
|
||||
worker.onmessage = (e: MessageEvent<WorkerOutMessage>) => this.onMessage(e.data);
|
||||
worker.onerror = (e) => this.failAll(new Error(`端侧识别 Worker 异常:${e.message}`));
|
||||
|
||||
const ready = new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 180_000); // WASM 初始化(含 data 解包)超时
|
||||
this.readyResolve = (ok: boolean) => {
|
||||
clearTimeout(timer);
|
||||
resolve(ok);
|
||||
};
|
||||
});
|
||||
|
||||
const msg: WorkerInMessage = { type: 'init', glueJs, apiJs, wasm, data };
|
||||
worker.postMessage(msg, [wasm, data]);
|
||||
this.isReady = await ready;
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
private readyResolve: ((ok: boolean) => void) | null = null;
|
||||
|
||||
private onMessage(msg: WorkerOutMessage): void {
|
||||
if (msg.type === 'ready') {
|
||||
this.isReady = true;
|
||||
this.readyResolve?.(true);
|
||||
this.readyResolve = null;
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
const err = new Error(`${msg.stage}: ${msg.message}`);
|
||||
if (this.readyResolve) {
|
||||
this.readyResolve?.(false);
|
||||
this.readyResolve = null;
|
||||
} else {
|
||||
this.failAll(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const entry = this.pending.get(msg.id);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
this.pending.delete(msg.id);
|
||||
entry.resolve({ text: msg.text, lang: msg.lang, emotion: msg.emotion, event: msg.event });
|
||||
}
|
||||
|
||||
private failAll(err: Error): void {
|
||||
for (const [, entry] of this.pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
async transcribe(pcm16k: Float32Array): Promise<OndeviceTranscript> {
|
||||
if (!this.worker || !this.isReady) throw new Error('端侧识别引擎未就绪');
|
||||
const id = this.nextId++;
|
||||
const msg: WorkerInMessage = { type: 'transcribe', id, pcm: pcm16k };
|
||||
this.worker.postMessage(msg, [pcm16k.buffer]);
|
||||
return new Promise<OndeviceTranscript>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error('端侧转写超时'));
|
||||
}, 600_000);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: OndeviceAsrEngine | null = null;
|
||||
|
||||
/** 进程内单例:VoiceInput 每次挂载复用同一个引擎(重复 init 是幂等的) */
|
||||
export function getOndeviceEngine(): OndeviceAsrEngine {
|
||||
singleton ??= new OndeviceAsrEngineImpl();
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/** 测试/verify 用:以假实现校验接口形状(真实 WASM 只能在浏览器里跑) */
|
||||
export function createMockOndeviceEngine(result: OndeviceTranscript = { text: 'mock 转写文本' }): OndeviceAsrEngine {
|
||||
return {
|
||||
ready: () => true,
|
||||
init: async () => true,
|
||||
transcribe: async (pcm) => {
|
||||
void segmentForSenseVoice(pcm); // mock 也走一遍切分,保持形状一致
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* SenseVoice 单次推理上限约 30 秒音频(见 docs/research/asr-options.md 4.1),
|
||||
* 更长的讲述必须在送 OfflineRecognizer 之前切分。
|
||||
*
|
||||
* 纯函数、无 Web API 依赖,verify 在 node 下直接测:
|
||||
* - 短音频原样返回单个分段;
|
||||
* - 长音频按 ≤ MAX_SEGMENT_SEC 切分,切点优先选边界附近的静音谷
|
||||
* (避免把词拦腰截断),找不到静音谷才硬切;
|
||||
* - 分段连续、无重叠、无遗漏(concat 后等于原序列)。
|
||||
*/
|
||||
|
||||
export const SENSE_VOICE_MAX_SEGMENT_SEC = 28;
|
||||
const SEARCH_BACK_SEC = 8;
|
||||
const FRAME_MS = 20;
|
||||
|
||||
function frameRms(pcm: Float32Array, start: number, len: number): number {
|
||||
let sum = 0;
|
||||
const end = Math.min(start + len, pcm.length);
|
||||
for (let i = start; i < end; i++) sum += pcm[i] * pcm[i];
|
||||
return end > start ? Math.sqrt(sum / (end - start)) : 0;
|
||||
}
|
||||
|
||||
/** 在 [limit - searchBack, limit] 内找 RMS 最小的帧起点作为切分点。 */
|
||||
function findSplitPoint(pcm: Float32Array, limit: number, frameLen: number, searchBack: number): number {
|
||||
const start = Math.max(0, limit - searchBack);
|
||||
let best = start;
|
||||
let bestRms = Infinity;
|
||||
for (let i = start; i + frameLen <= limit; i += frameLen) {
|
||||
const rms = frameRms(pcm, i, frameLen);
|
||||
if (rms < bestRms) {
|
||||
bestRms = rms;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function segmentForSenseVoice(
|
||||
pcm: Float32Array,
|
||||
sampleRate = 16000,
|
||||
): Float32Array[] {
|
||||
if (pcm.length === 0) return [];
|
||||
const maxLen = Math.floor(SENSE_VOICE_MAX_SEGMENT_SEC * sampleRate);
|
||||
if (pcm.length <= maxLen) return [pcm];
|
||||
|
||||
const frameLen = Math.max(1, Math.floor((FRAME_MS / 1000) * sampleRate));
|
||||
const searchBack = Math.floor(SEARCH_BACK_SEC * sampleRate);
|
||||
|
||||
const segments: Float32Array[] = [];
|
||||
let offset = 0;
|
||||
while (offset < pcm.length) {
|
||||
if (pcm.length - offset <= maxLen) {
|
||||
segments.push(pcm.subarray(offset));
|
||||
break;
|
||||
}
|
||||
const split = findSplitPoint(pcm, offset + maxLen, frameLen, searchBack);
|
||||
segments.push(pcm.subarray(offset, split));
|
||||
offset = split;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
+93
-1
@@ -1,8 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { createPcm16kRecorder } from '../asr/capture';
|
||||
import type { Pcm16kRecorder } from '../asr/capture';
|
||||
import { getOndeviceEngine, getOndeviceLoadProgress, ondeviceAsrEnabled } from '../asr/ondevice';
|
||||
|
||||
/**
|
||||
* 语音讲述输入。三条通路,按能力自动选择,文本框永远可用:
|
||||
* 语音讲述输入。四条通路,按能力自动选择,文本框永远可用:
|
||||
*
|
||||
* 0. 端侧转写(第一优先,模型就绪时):sherpa-onnx WASM + SenseVoiceSmall 在本地
|
||||
* 转写,音频不出设备(ADR-0004 最强"即焚",只留转写文本)。模型首次使用懒加载
|
||||
* (进度见下方提示),未就绪/失败时静默退回后面的通路。`VITE_ONDEVICE_ASR=0` 关闭;
|
||||
* 1. SpeechRecognition(桌面 Chrome/Edge):点按开始/结束,实时转写,沉默自动续录;
|
||||
* 2. MediaRecorder 录音上传(Tauri WKWebView / 不支持 SR 的环境):
|
||||
* 录音 → 上传 server 的 /api/asr → 转写文本回填进可编辑文本框;
|
||||
@@ -61,20 +68,88 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
||||
const SR = useMemo(() => getSRCtor(), []);
|
||||
const [recording, setRecording] = useState(false); // SR 识别中
|
||||
const [recAudio, setRecAudio] = useState(false); // MediaRecorder 录音中
|
||||
const [recOndevice, setRecOndevice] = useState(false); // 端侧录音中
|
||||
const [interim, setInterim] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 端侧引擎状态:loading(首次懒加载,含下载进度)/ ready / failed / disabled
|
||||
const [ondevice, setOndevice] = useState<'loading' | 'ready' | 'failed' | 'disabled'>(() =>
|
||||
ondeviceAsrEnabled() ? 'loading' : 'disabled',
|
||||
);
|
||||
const [dlProgress, setDlProgress] = useState<{ loaded: number; total: number } | null>(() =>
|
||||
getOndeviceLoadProgress(),
|
||||
);
|
||||
|
||||
const recRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const wantRef = useRef(false);
|
||||
const fatalRef = useRef(false);
|
||||
const mediaRecRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const pcmRecRef = useRef<Pcm16kRecorder | null>(null);
|
||||
|
||||
/** SR 识别引擎不可用(无 SR 或已致命失败)时,改用录音上传通路 */
|
||||
const useAudioPath = !SR || fatalRef.current;
|
||||
|
||||
// ---- 通路 0:端侧转写(sherpa-onnx WASM + SenseVoiceSmall,音频不出设备) ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!ondeviceAsrEnabled()) return;
|
||||
let alive = true;
|
||||
getOndeviceEngine()
|
||||
.init((loaded, total) => {
|
||||
if (alive) setDlProgress({ loaded, total });
|
||||
})
|
||||
.then((ok) => {
|
||||
if (alive) setOndevice(ok ? 'ready' : 'failed');
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function startOndevice() {
|
||||
setHint(null);
|
||||
const rec = createPcm16kRecorder();
|
||||
try {
|
||||
await rec.start();
|
||||
} catch {
|
||||
setHint('麦克风不可用或未授权——直接打字也完全没问题。');
|
||||
return;
|
||||
}
|
||||
pcmRecRef.current = rec;
|
||||
setRecOndevice(true);
|
||||
}
|
||||
|
||||
async function stopOndevice() {
|
||||
const rec = pcmRecRef.current;
|
||||
if (!rec) return;
|
||||
pcmRecRef.current = null;
|
||||
setRecOndevice(false);
|
||||
setBusy(true);
|
||||
setHint(null);
|
||||
try {
|
||||
const pcm = rec.stop();
|
||||
if (pcm.length < 16000 * 0.3) {
|
||||
setHint('这段太短没听清——可以重说一遍,或直接打字。');
|
||||
return;
|
||||
}
|
||||
const t = await getOndeviceEngine().transcribe(pcm);
|
||||
const transcribed = t.text.trim();
|
||||
if (transcribed) {
|
||||
// SenseVoice 的 emotion/event 标签已随结果返回(t.emotion/t.event),
|
||||
// 等讲述事件接线后再进画像;回填文本框第一版只用文本。
|
||||
setText((prev) => (prev ? prev + transcribed : transcribed));
|
||||
} else {
|
||||
setHint('端侧转写没听清这段——可以重说一遍,或直接打字。');
|
||||
}
|
||||
} catch {
|
||||
setHint('端侧转写失败——可以改用录音上传,或直接打字。');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 通路 1:SpeechRecognition ----
|
||||
|
||||
function launchSr() {
|
||||
@@ -205,6 +280,7 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
||||
} catch {
|
||||
// 卸载清理
|
||||
}
|
||||
pcmRecRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -240,6 +316,16 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
||||
}
|
||||
/>
|
||||
<div className="voice-controls">
|
||||
{ondevice === 'ready' &&
|
||||
(recOndevice ? (
|
||||
<button type="button" className="hold-btn holding" disabled={disabled} onClick={stopOndevice}>
|
||||
■ 停止并转写
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="hold-btn" disabled={disabled || busy} onClick={startOndevice}>
|
||||
⬤ {text ? '继续端侧讲述' : '端侧讲述'}
|
||||
</button>
|
||||
))}
|
||||
{!useAudioPath &&
|
||||
(recording ? (
|
||||
<button type="button" className="hold-btn holding" disabled={disabled} onClick={stopSr}>
|
||||
@@ -262,6 +348,12 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{ondevice === 'loading' && dlProgress && dlProgress.total > 0 && (
|
||||
<p className="voice-live">
|
||||
端侧识别模型首次加载中 {Math.floor((dlProgress.loaded / dlProgress.total) * 100)}%(之后不再下载)——等不及可以先用下面的录音或打字。
|
||||
</p>
|
||||
)}
|
||||
{recOndevice && <p className="voice-live">端侧讲述中——音频只在本机转写,说完点「停止并转写」。</p>}
|
||||
{recording && <p className="voice-live">讲述中——停顿会自动续录,说完点「结束讲述」。</p>}
|
||||
{recAudio && <p className="voice-live">录音中——说完点「停止并转写」。</p>}
|
||||
{hint && <p className="voice-live">{hint}</p>}
|
||||
|
||||
Vendored
+2
@@ -3,6 +3,8 @@
|
||||
interface ImportMetaEnv {
|
||||
/** TypeSafe AI System One (Jev) API key;缺失时自动降级 MockJevClient */
|
||||
readonly VITE_TYPESAFE_API_KEY?: string;
|
||||
/** 端侧小模型转写(sherpa-onnx WASM + SenseVoiceSmall)开关;'0' 关闭,默认开 */
|
||||
readonly VITE_ONDEVICE_ASR?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user