dsh-her 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/cordis.patch.yml +5 -0
  4. package/examples/workshop/README.md +20 -0
  5. package/lib/client.js +1602 -0
  6. package/lib/core/character.js +50 -0
  7. package/lib/core/cues.js +62 -0
  8. package/lib/core/echo.js +27 -0
  9. package/lib/core/index.js +10 -0
  10. package/lib/core/paths.js +14 -0
  11. package/lib/core/prompt.js +63 -0
  12. package/lib/core/sentences.js +115 -0
  13. package/lib/core/speakable.js +26 -0
  14. package/lib/core/voices.js +43 -0
  15. package/lib/core/wav.js +71 -0
  16. package/lib/core/workshop.js +330 -0
  17. package/lib/dsh/api.js +156 -0
  18. package/lib/dsh/body-install.js +137 -0
  19. package/lib/dsh/body-launcher.js +48 -0
  20. package/lib/dsh/config.js +27 -0
  21. package/lib/dsh/her-session.js +282 -0
  22. package/lib/dsh/import-status.js +6 -0
  23. package/lib/dsh/plugin.js +1010 -0
  24. package/lib/dsh/workshop.js +222 -0
  25. package/lib/index.js +11 -0
  26. package/lib/memory/digest.js +119 -0
  27. package/lib/memory/search.js +52 -0
  28. package/lib/memory/store.js +267 -0
  29. package/lib/protocol/index.js +39 -0
  30. package/lib/server/brain-server.js +219 -0
  31. package/lib/types/core/character.d.ts +32 -0
  32. package/lib/types/core/cues.d.ts +20 -0
  33. package/lib/types/core/echo.d.ts +10 -0
  34. package/lib/types/core/index.d.ts +10 -0
  35. package/lib/types/core/paths.d.ts +11 -0
  36. package/lib/types/core/prompt.d.ts +39 -0
  37. package/lib/types/core/sentences.d.ts +40 -0
  38. package/lib/types/core/speakable.d.ts +6 -0
  39. package/lib/types/core/voices.d.ts +23 -0
  40. package/lib/types/core/wav.d.ts +9 -0
  41. package/lib/types/core/workshop.d.ts +156 -0
  42. package/lib/types/dsh/api.d.ts +163 -0
  43. package/lib/types/dsh/body-install.d.ts +58 -0
  44. package/lib/types/dsh/body-launcher.d.ts +28 -0
  45. package/lib/types/dsh/config.d.ts +34 -0
  46. package/lib/types/dsh/her-session.d.ts +215 -0
  47. package/lib/types/dsh/import-status.d.ts +17 -0
  48. package/lib/types/dsh/plugin.d.ts +34 -0
  49. package/lib/types/dsh/workshop.d.ts +94 -0
  50. package/lib/types/index.d.ts +11 -0
  51. package/lib/types/memory/digest.d.ts +33 -0
  52. package/lib/types/memory/search.d.ts +17 -0
  53. package/lib/types/memory/store.d.ts +132 -0
  54. package/lib/types/protocol/index.d.ts +434 -0
  55. package/lib/types/server/brain-server.d.ts +71 -0
  56. package/lib/types/voice/cloud-key.d.ts +10 -0
  57. package/lib/types/voice/cosyvoice.d.ts +47 -0
  58. package/lib/types/voice/edge.d.ts +52 -0
  59. package/lib/types/voice/engine.d.ts +30 -0
  60. package/lib/types/voice/models.d.ts +21 -0
  61. package/lib/types/voice/speech-queue.d.ts +55 -0
  62. package/lib/types/voice/voice.d.ts +75 -0
  63. package/lib/types/voice/worker.d.ts +37 -0
  64. package/lib/voice/cloud-key.js +23 -0
  65. package/lib/voice/cosyvoice.js +194 -0
  66. package/lib/voice/edge.js +199 -0
  67. package/lib/voice/engine.js +101 -0
  68. package/lib/voice/models.js +69 -0
  69. package/lib/voice/speech-queue.js +118 -0
  70. package/lib/voice/voice.js +171 -0
  71. package/lib/voice/worker.js +47 -0
  72. package/package.json +61 -4
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Microsoft Edge "Read aloud" voices (晓晓, 晓伊 …) through the open-source
3
+ * command-line tool edge-tts (https://github.com/rany2/edge-tts), the same
4
+ * way dsh-audiobook does it: we never talk to the service ourselves.
5
+ *
6
+ * Where the tool comes from, first found wins:
7
+ * 1. one already installed by dsh-her (`<home>/edge-tts`), dsh-audiobook or
8
+ * dsh-tingdong (`~/.dsh/<plugin>/edge-tts`), or on the PATH via Homebrew;
9
+ * 2. otherwise it is installed in the background into `<home>/edge-tts`,
10
+ * a Python virtual environment of its own, built with the computer's
11
+ * Python 3.8+ if it has one;
12
+ * 3. without one (a new Mac has only a /usr/bin/python3 that asks to install
13
+ * the developer tools), uv (https://github.com/astral-sh/uv, one pinned,
14
+ * checksummed download) fetches a Python of its own into `<home>/uv`
15
+ * and builds the environment with it. No dialogs, nothing system-wide.
16
+ * Deleting `<home>/edge-tts` (and `<home>/uv`) uninstalls it.
17
+ *
18
+ * One process per sentence, so each pays for a fresh connection (about 2.5 s
19
+ * measured on 2026-10-29); the speech queue synthesizes ahead to hide it.
20
+ * Needs the internet; not an official Microsoft API, so it may stop working.
21
+ */
22
+ import { execFile } from 'node:child_process';
23
+ import { createHash, randomUUID } from 'node:crypto';
24
+ import { existsSync } from 'node:fs';
25
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
26
+ import { homedir, tmpdir } from 'node:os';
27
+ import { dirname, join } from 'node:path';
28
+ /** audio-24khz-48kbitrate-mono-mp3: constant bitrate, so bytes give the duration. */
29
+ const MP3_BYTES_PER_MS = 48_000 / 8 / 1000;
30
+ const SPEAK_TIMEOUT_MS = 20_000;
31
+ const win = process.platform === 'win32';
32
+ function run(cmd, args, timeout, env) {
33
+ return new Promise((resolve, reject) => {
34
+ execFile(cmd, args, { timeout, maxBuffer: 8 << 20, ...(env === undefined ? {} : { env: { ...process.env, ...env } }) }, (error, stdout, stderr) => {
35
+ if (error !== null)
36
+ reject(new Error(String(stderr || error.message).trim().split('\n').slice(-2).join(' ')));
37
+ else
38
+ resolve(String(stdout));
39
+ });
40
+ });
41
+ }
42
+ const venvBin = (root, name) => join(root, win ? 'Scripts' : 'bin', win ? `${name}.exe` : name);
43
+ /** `--rate` for a speed factor: 1.2 → "+20%". */
44
+ export function edgeRate(speed) {
45
+ const percent = Math.round((speed - 1) * 100);
46
+ return `${percent >= 0 ? '+' : ''}${percent}%`;
47
+ }
48
+ export class EdgeVoice {
49
+ ownDir;
50
+ installing;
51
+ state = 'missing';
52
+ error;
53
+ viaUv;
54
+ /** `ownDir`: where dsh-her installs its own copy (`<home>/edge-tts`). `viaUv`: skip the computer's Python (tests). */
55
+ constructor(ownDir, options = {}) {
56
+ this.ownDir = ownDir;
57
+ this.viaUv = options.viaUv === true;
58
+ if (this.bin() !== undefined)
59
+ this.state = 'ready';
60
+ }
61
+ get status() {
62
+ return this.error === undefined ? { status: this.state } : { status: this.state, error: this.error };
63
+ }
64
+ /** The edge-tts command, or undefined when there is none yet. */
65
+ bin() {
66
+ const dsh = join(homedir(), '.dsh');
67
+ const roots = this.viaUv ? [this.ownDir] : [this.ownDir, join(dsh, 'dsh-audiobook', 'edge-tts'), join(dsh, 'dsh-tingdong', 'edge-tts')];
68
+ for (const root of roots) {
69
+ if (existsSync(venvBin(root, 'edge-tts')))
70
+ return venvBin(root, 'edge-tts');
71
+ }
72
+ return this.viaUv ? undefined : ['/opt/homebrew/bin/edge-tts', '/usr/local/bin/edge-tts', '/usr/bin/edge-tts'].find(p => existsSync(p));
73
+ }
74
+ /** Make sure the tool is there, installing it once if needed. */
75
+ ensure() {
76
+ if (this.bin() !== undefined) {
77
+ this.state = 'ready';
78
+ return Promise.resolve();
79
+ }
80
+ this.installing ??= this.install().finally(() => { this.installing = undefined; });
81
+ return this.installing;
82
+ }
83
+ /** One sentence as MP3. */
84
+ async synthesize(text, voice, speed) {
85
+ await this.ensure();
86
+ const bin = this.bin();
87
+ if (bin === undefined)
88
+ throw new Error('Edge 语音组件还没装好');
89
+ const started = Date.now();
90
+ const out = join(tmpdir(), `dsh-her-${randomUUID()}.mp3`);
91
+ try {
92
+ // --text=…: a sentence starting with "-" is not taken for an option.
93
+ const args = ['--voice', voice, `--rate=${edgeRate(speed)}`, `--text=${text}`, '--write-media', out];
94
+ try {
95
+ await run(bin, args, SPEAK_TIMEOUT_MS);
96
+ }
97
+ catch {
98
+ // The network hiccups now and then: once more.
99
+ await run(bin, args, SPEAK_TIMEOUT_MS);
100
+ }
101
+ const audio = new Uint8Array(await readFile(out));
102
+ if (audio.byteLength === 0)
103
+ throw new Error('Edge 没有返回声音');
104
+ return { audio, durationMs: Math.round(audio.byteLength / MP3_BYTES_PER_MS), ms: Date.now() - started };
105
+ }
106
+ finally {
107
+ await rm(out, { force: true });
108
+ }
109
+ }
110
+ async install() {
111
+ this.state = 'installing';
112
+ this.error = undefined;
113
+ try {
114
+ const python = this.viaUv ? undefined : await findPython();
115
+ const venvPython = venvBin(this.ownDir, win ? 'python' : 'python3');
116
+ if (python !== undefined) {
117
+ if (!existsSync(venvPython))
118
+ await run(python, ['-m', 'venv', this.ownDir], 120_000);
119
+ await run(venvPython, ['-m', 'pip', 'install', '-q', '--disable-pip-version-check', '--upgrade', 'edge-tts'], 300_000);
120
+ }
121
+ else {
122
+ // No Python here: uv brings one of its own, kept next to edge-tts.
123
+ const uvHome = join(dirname(this.ownDir), 'uv');
124
+ const uv = await installUv(uvHome);
125
+ const env = { UV_PYTHON_INSTALL_DIR: join(uvHome, 'python'), UV_CACHE_DIR: join(uvHome, 'cache'), UV_NO_CONFIG: '1' };
126
+ if (!existsSync(venvPython))
127
+ await run(uv, ['venv', '--quiet', '--python', '3.12', this.ownDir], 600_000, env);
128
+ await run(uv, ['pip', 'install', '--quiet', '--python', venvPython, '--upgrade', 'edge-tts'], 300_000, env);
129
+ // The downloads are installed now; only the Python itself must stay.
130
+ await rm(env.UV_CACHE_DIR, { recursive: true, force: true });
131
+ }
132
+ if (this.bin() === undefined)
133
+ throw new Error('装完了却找不到 edge-tts');
134
+ this.state = 'ready';
135
+ }
136
+ catch (error) {
137
+ this.state = 'failed';
138
+ this.error = `安装 Edge 语音组件失败:${error.message}`;
139
+ throw new Error(this.error);
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * A Python 3.8+ to build the virtual environment with. On a Mac,
145
+ * /usr/bin/python3 (and a bare `python3`, which is the same) only work once
146
+ * the developer tools are installed; before that, running them pops up the
147
+ * installer, so they are not even tried.
148
+ */
149
+ async function findPython() {
150
+ const devTools = process.platform !== 'darwin' || await run('/usr/bin/xcode-select', ['-p'], 10_000).then(() => true, () => false);
151
+ const candidates = win ? ['py', 'python', 'python3'] : ['/opt/homebrew/bin/python3', '/usr/local/bin/python3', ...(devTools ? ['/usr/bin/python3', 'python3'] : [])];
152
+ for (const python of candidates) {
153
+ if (python.includes('/') && !existsSync(python))
154
+ continue;
155
+ try {
156
+ const [major, minor] = (await run(python, ['-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], 15_000)).trim().split('.').map(Number);
157
+ if (major === 3 && minor >= 8)
158
+ return python;
159
+ }
160
+ catch {
161
+ // the next one
162
+ }
163
+ }
164
+ return undefined;
165
+ }
166
+ /** uv, pinned: version and the SHA-256 of each download (from the release's .sha256 files). */
167
+ const UV_VERSION = '0.11.26';
168
+ const UV_ASSETS = {
169
+ 'darwin-arm64': { file: 'uv-aarch64-apple-darwin.tar.gz', sha256: '8f7fbf1708399b921857bce71e1d60f0d3ccf52a30caebc1c1a2f175dce13ab6' },
170
+ 'darwin-x64': { file: 'uv-x86_64-apple-darwin.tar.gz', sha256: '922b460202707dd5f4ccacbadbe7f6a546cc46e82a99bf50ca99a7977a78eddd' },
171
+ 'win32-x64': { file: 'uv-x86_64-pc-windows-msvc.zip', sha256: '4e1278ede866be6c0bf32d2f466cc6de7a9fb399ecf20c9ce2d186e52424be47' },
172
+ };
173
+ /** The uv binary in `uvHome`, downloaded and checked the first time. */
174
+ async function installUv(uvHome) {
175
+ const key = `${process.platform}-${process.arch}`;
176
+ const asset = UV_ASSETS[key];
177
+ if (asset === undefined)
178
+ throw new Error(`这台电脑(${key})没有 Python,也没有现成的办法自动装:请先装 Python 3`);
179
+ const dir = join(uvHome, UV_VERSION);
180
+ const found = [join(dir, 'uv'), join(dir, 'uv.exe'), join(dir, asset.file.replace(/\.(tar\.gz|zip)$/, ''), 'uv')].find(p => existsSync(p));
181
+ if (found !== undefined)
182
+ return found;
183
+ const res = await fetch(`https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${asset.file}`, { redirect: 'follow', signal: AbortSignal.timeout(300_000) });
184
+ if (!res.ok)
185
+ throw new Error(`下载 uv 失败(${res.status})`);
186
+ const bytes = Buffer.from(await res.arrayBuffer());
187
+ if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256)
188
+ throw new Error('下载的 uv 校验不对,没有使用');
189
+ await mkdir(dir, { recursive: true });
190
+ const archive = join(dir, asset.file);
191
+ await writeFile(archive, bytes);
192
+ // tar reads both the .tar.gz and (Windows 10+) the .zip.
193
+ await run('tar', ['-xf', archive, '-C', dir], 120_000);
194
+ await rm(archive, { force: true });
195
+ const uv = [join(dir, 'uv'), join(dir, 'uv.exe'), join(dir, asset.file.replace(/\.(tar\.gz|zip)$/, ''), 'uv')].find(p => existsSync(p));
196
+ if (uv === undefined)
197
+ throw new Error('uv 解压后找不到');
198
+ return uv;
199
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Parent side of the local speech recognition child process. Requests are answered in order of completion,
3
+ * matched by id. If the child dies, pending requests fail and the next call
4
+ * starts a fresh one.
5
+ *
6
+ * Inside the DSH desktop app `process.execPath` is Electron, so the child is
7
+ * started with ELECTRON_RUN_AS_NODE. The sherpa native library directory is
8
+ * put on the loader path, as sherpa-onnx documents.
9
+ */
10
+ import { fork } from 'node:child_process';
11
+ import { createRequire } from 'node:module';
12
+ import { dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ /** Directory of the platform package holding sherpa's shared libraries, if installed. */
15
+ function nativeLibDir() {
16
+ try {
17
+ const require = createRequire(import.meta.url);
18
+ return dirname(require.resolve(`sherpa-onnx-${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}/package.json`));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ function childEnv() {
25
+ const env = { ...process.env };
26
+ if (process.versions.electron !== undefined)
27
+ env.ELECTRON_RUN_AS_NODE = '1';
28
+ const lib = nativeLibDir();
29
+ if (lib !== undefined) {
30
+ const key = process.platform === 'darwin' ? 'DYLD_LIBRARY_PATH' : process.platform === 'win32' ? 'PATH' : 'LD_LIBRARY_PATH';
31
+ const sep = process.platform === 'win32' ? ';' : ':';
32
+ env[key] = env[key] ? `${lib}${sep}${env[key]}` : lib;
33
+ }
34
+ return env;
35
+ }
36
+ export class LocalVoice {
37
+ child;
38
+ nextId = 1;
39
+ pending = new Map();
40
+ ready;
41
+ init;
42
+ constructor(init) {
43
+ this.init = init;
44
+ }
45
+ /** Load the models (once); resolves with how long loading took. */
46
+ start() {
47
+ this.ready ??= this.request({ op: 'init', init: this.init }).then(reply => {
48
+ if (reply.op !== 'init')
49
+ throw new Error('unexpected reply');
50
+ return { loadMs: reply.loadMs };
51
+ });
52
+ return this.ready;
53
+ }
54
+ async recognize(samples, sampleRate) {
55
+ await this.start();
56
+ const reply = await this.request({ op: 'asr', samples, sampleRate });
57
+ if (reply.op !== 'asr')
58
+ throw new Error('unexpected reply');
59
+ return { text: reply.text, lang: reply.lang, ms: reply.ms };
60
+ }
61
+ stop() {
62
+ this.child?.kill();
63
+ this.child = undefined;
64
+ this.ready = undefined;
65
+ }
66
+ spawn() {
67
+ const here = fileURLToPath(import.meta.url);
68
+ const worker = here.replace(/engine\.(ts|js)$/, 'worker.$1');
69
+ const child = fork(worker, [], { env: childEnv(), serialization: 'advanced', stdio: ['ignore', 'inherit', 'inherit', 'ipc'] });
70
+ child.on('message', (reply) => {
71
+ const waiter = this.pending.get(reply.id);
72
+ if (waiter === undefined)
73
+ return;
74
+ this.pending.delete(reply.id);
75
+ if (reply.ok)
76
+ waiter.resolve(reply);
77
+ else
78
+ waiter.reject(new Error(reply.error));
79
+ });
80
+ child.on('exit', (code, signal) => {
81
+ if (this.child === child) {
82
+ this.child = undefined;
83
+ this.ready = undefined;
84
+ }
85
+ const error = new Error(`语音子进程退出了(${signal ?? code})`);
86
+ for (const waiter of this.pending.values())
87
+ waiter.reject(error);
88
+ this.pending.clear();
89
+ });
90
+ return child;
91
+ }
92
+ request(body) {
93
+ this.child ??= this.spawn();
94
+ const id = this.nextId++;
95
+ const child = this.child;
96
+ return new Promise((resolve, reject) => {
97
+ this.pending.set(id, { resolve, reject });
98
+ child.send({ ...body, id });
99
+ });
100
+ }
101
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Local speech recognition models (sherpa-onnx). They are downloaded on first
3
+ * use into `<home>/models`, never shipped in the package. Her own voice is in
4
+ * the cloud (cosyvoice.ts). Design: docs/语音方案.md.
5
+ */
6
+ import { execFile } from 'node:child_process';
7
+ import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { Readable } from 'node:stream';
10
+ import { pipeline } from 'node:stream/promises';
11
+ import { promisify } from 'node:util';
12
+ const RELEASES = 'https://github.com/k2-fsa/sherpa-onnx/releases/download';
13
+ export const MODELS = [
14
+ {
15
+ id: 'asr',
16
+ name: 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09',
17
+ url: `${RELEASES}/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2`,
18
+ bytes: 165_783_878,
19
+ archive: true,
20
+ },
21
+ {
22
+ id: 'vad',
23
+ name: 'silero_vad.onnx',
24
+ url: `${RELEASES}/asr-models/silero_vad.onnx`,
25
+ bytes: 643_854,
26
+ archive: false,
27
+ },
28
+ ];
29
+ export function modelPath(modelsDir, spec) {
30
+ return join(modelsDir, spec.name);
31
+ }
32
+ export function missingModels(modelsDir) {
33
+ return MODELS.filter(spec => !existsSync(modelPath(modelsDir, spec)));
34
+ }
35
+ /**
36
+ * Download and unpack one model. Writes to a temporary name first, so an
37
+ * interrupted download never looks installed.
38
+ */
39
+ export async function fetchModel(modelsDir, spec, onProgress) {
40
+ mkdirSync(modelsDir, { recursive: true });
41
+ const response = await fetch(spec.url);
42
+ if (!response.ok || response.body === null)
43
+ throw new Error(`下载失败 ${spec.url}(HTTP ${response.status})`);
44
+ const total = Number(response.headers.get('content-length') ?? spec.bytes);
45
+ const partial = join(modelsDir, `${spec.name}.download`);
46
+ let received = 0;
47
+ const body = Readable.fromWeb(response.body);
48
+ body.on('data', (chunk) => {
49
+ received += chunk.byteLength;
50
+ onProgress?.(received, total);
51
+ });
52
+ await pipeline(body, createWriteStream(partial));
53
+ if (!spec.archive) {
54
+ renameSync(partial, modelPath(modelsDir, spec));
55
+ return;
56
+ }
57
+ const staging = join(modelsDir, `${spec.name}.unpack`);
58
+ rmSync(staging, { recursive: true, force: true });
59
+ mkdirSync(staging, { recursive: true });
60
+ try {
61
+ // bsdtar ships with macOS and Windows 10+, and reads bzip2 itself.
62
+ await promisify(execFile)('tar', ['-xjf', partial, '-C', staging]);
63
+ renameSync(join(staging, spec.name), modelPath(modelsDir, spec));
64
+ }
65
+ finally {
66
+ rmSync(staging, { recursive: true, force: true });
67
+ rmSync(partial, { force: true });
68
+ }
69
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Turns her replies into audio sentence by sentence, in order.
3
+ *
4
+ * The next few sentences are synthesized at once (AHEAD), so while one plays
5
+ * the following ones are already on their way; a slow voice service only
6
+ * makes the first sentence of a reply wait. Sentences are started and
7
+ * emitted strictly in order, across replies, so the first sentence of a reply
8
+ * is never queued behind a later one. Each result is emitted as soon as it
9
+ * and everything before it are ready (the body queues playback), and a reply's
10
+ * end is emitted after its last sentence. Cancelling a reply drops what has
11
+ * not been emitted yet; synthesis already running for it is thrown away.
12
+ *
13
+ * A sentence whose synthesis fails is still emitted, without audio, so the
14
+ * subtitle shows and the reply goes on.
15
+ */
16
+ /** Sentences being synthesized at the same time, counted from the next to emit. */
17
+ const AHEAD = 3;
18
+ export class SpeechQueue {
19
+ jobs = [];
20
+ running = false;
21
+ cancelled = new Set();
22
+ sent = new Map();
23
+ synth;
24
+ out;
25
+ constructor(synth, out) {
26
+ this.synth = synth;
27
+ this.out = out;
28
+ }
29
+ say(uid, seq, text, cues = []) {
30
+ if (this.cancelled.has(uid))
31
+ return;
32
+ this.jobs.push({ uid, kind: 'sentence', seq, text, cues });
33
+ this.startAhead();
34
+ void this.pump();
35
+ }
36
+ end(uid) {
37
+ if (this.cancelled.has(uid))
38
+ return;
39
+ this.jobs.push({ uid, kind: 'end' });
40
+ void this.pump();
41
+ }
42
+ cancel(uid) {
43
+ this.cancelled.add(uid);
44
+ for (let i = this.jobs.length - 1; i >= 0; i--)
45
+ if (this.jobs[i].uid === uid)
46
+ this.jobs.splice(i, 1);
47
+ // Remember only recent cancellations.
48
+ if (this.cancelled.size > 32)
49
+ this.cancelled.delete(this.cancelled.values().next().value);
50
+ }
51
+ /** Sentences of a reply that went out, in order. */
52
+ sentOf(uid) {
53
+ return this.sent.get(uid) ?? [];
54
+ }
55
+ /** Resolves when nothing is queued or being synthesized. */
56
+ async idle() {
57
+ while (this.running || this.jobs.length > 0)
58
+ await new Promise(resolve => setTimeout(resolve, 5));
59
+ }
60
+ /** Start synthesizing the first AHEAD sentences still to emit. */
61
+ startAhead() {
62
+ let sentences = 0;
63
+ for (const job of this.jobs) {
64
+ if (job.kind !== 'sentence')
65
+ continue;
66
+ if (++sentences > AHEAD)
67
+ break;
68
+ job.audio ??= this.synth(job.text).catch(() => undefined);
69
+ }
70
+ }
71
+ async pump() {
72
+ if (this.running)
73
+ return;
74
+ this.running = true;
75
+ try {
76
+ for (let job = this.jobs[0]; job !== undefined; job = this.jobs[0]) {
77
+ if (job.kind === 'end') {
78
+ this.jobs.shift();
79
+ this.out.end(job.uid);
80
+ continue;
81
+ }
82
+ this.startAhead();
83
+ const audio = await job.audio;
84
+ // Cancelled while it was synthesized: cancel() already took it out.
85
+ if (this.jobs[0] !== job)
86
+ continue;
87
+ this.jobs.shift();
88
+ this.startAhead();
89
+ const record = this.sent.get(job.uid) ?? [];
90
+ record.push({ seq: job.seq, text: job.text, durationMs: audio?.durationMs ?? 0 });
91
+ this.sent.set(job.uid, record);
92
+ if (this.sent.size > 16)
93
+ this.sent.delete(this.sent.keys().next().value);
94
+ this.out.sentence(job.uid, job.seq, job.text, audio, job.cues);
95
+ }
96
+ }
97
+ finally {
98
+ this.running = false;
99
+ }
100
+ }
101
+ }
102
+ /**
103
+ * How much of a reply was heard when it was cut off: every sentence before
104
+ * the one playing, plus the share of that one its played time covers.
105
+ */
106
+ export function heardText(sent, seq, playedMs) {
107
+ let heard = '';
108
+ for (const sentence of sent) {
109
+ if (sentence.seq < seq)
110
+ heard += sentence.text;
111
+ else if (sentence.seq === seq) {
112
+ const chars = [...sentence.text];
113
+ const share = sentence.durationMs > 0 ? Math.min(1, Math.max(0, playedMs / sentence.durationMs)) : 0;
114
+ heard += chars.slice(0, Math.round(chars.length * share)).join('');
115
+ }
116
+ }
117
+ return heard;
118
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Her voice for the brain.
3
+ *
4
+ * - Listening: local speech recognition (sherpa-onnx SenseVoice in a child
5
+ * process, engine.ts). Its model is downloaded in the background on first
6
+ * use; until then she only reads what you type.
7
+ * - Speaking: the service of the voice she uses (core/voices.ts): Microsoft
8
+ * Edge voices through the edge-tts tool (edge.ts, the default), or Alibaba
9
+ * Cloud Model Studio CosyVoice (cosyvoice.ts). When the service cannot
10
+ * speak (tool not installed yet, no key, no quota, offline), a sentence goes
11
+ * out without audio and shows as text; a key added later is picked up
12
+ * without restarting.
13
+ * - Cache: short sentences ("嗯。" "好呀。") are kept, so the most common
14
+ * answers start at once.
15
+ */
16
+ import { serviceOf, VOICE_MODEL } from "../core/index.js";
17
+ import { LocalVoice } from "./engine.js";
18
+ import { CloudAccountError, CosyVoice } from "./cosyvoice.js";
19
+ import { EdgeVoice } from "./edge.js";
20
+ import { readCloudKey } from "./cloud-key.js";
21
+ import { fetchModel, missingModels, modelPath, MODELS } from "./models.js";
22
+ const CACHE_MAX_CHARS = 8;
23
+ const CACHE_ENTRIES = 64;
24
+ /** After "no key" or "no quota", try the cloud again at most this often (a key added later is picked up). */
25
+ const KEY_RECHECK_MS = 30_000;
26
+ export class Voice {
27
+ asr;
28
+ listening = 'loading';
29
+ speakingState;
30
+ speakingError;
31
+ keyCheckedAt = 0;
32
+ cloud;
33
+ edge;
34
+ cache = new Map();
35
+ modelsDir;
36
+ config;
37
+ logger;
38
+ /** `edgeDir`: where dsh-her installs its own edge-tts if none is found. */
39
+ constructor(modelsDir, edgeDir, config, logger) {
40
+ this.modelsDir = modelsDir;
41
+ this.config = config;
42
+ this.logger = logger;
43
+ this.speakingState = config.enabled ? 'checking' : 'off';
44
+ this.cloud = new CosyVoice(VOICE_MODEL, readCloudKey);
45
+ this.edge = new EdgeVoice(edgeDir);
46
+ }
47
+ /** Listening. */
48
+ get status() {
49
+ return this.listening;
50
+ }
51
+ get speaking() {
52
+ const service = serviceOf(this.config.voice);
53
+ return this.speakingError === undefined ? { status: this.speakingState, service } : { status: this.speakingState, service, error: this.speakingError };
54
+ }
55
+ /** Check the cloud voice and get recognition ready. Never throws; the statuses say how it went. */
56
+ async start() {
57
+ if (this.config.enabled)
58
+ void this.speak('嗯。').catch(() => undefined);
59
+ try {
60
+ const missing = missingModels(this.modelsDir);
61
+ if (missing.length > 0) {
62
+ this.listening = 'downloading';
63
+ this.logger.info(`dsh-her: downloading speech recognition models (${missing.map(m => m.id).join(', ')}) into ${this.modelsDir}`);
64
+ for (const spec of missing)
65
+ await fetchModel(this.modelsDir, spec);
66
+ }
67
+ this.listening = 'loading';
68
+ this.asr = new LocalVoice({ asrDir: modelPath(this.modelsDir, MODELS.find(m => m.id === 'asr')), threads: this.config.threads });
69
+ const { loadMs } = await this.asr.start();
70
+ this.listening = 'ready';
71
+ this.logger.info(`dsh-her: speech recognition ready (${loadMs} ms)`);
72
+ }
73
+ catch (error) {
74
+ this.listening = 'failed';
75
+ this.logger.warn(`dsh-her: speech recognition unavailable, typing only (${error.message})`);
76
+ }
77
+ }
78
+ /** You started talking: open the cloud connection now, while your words are recognized. */
79
+ warm() {
80
+ if (serviceOf(this.config.voice) === 'cosyvoice' && (this.speakingState === 'ready' || this.speakingState === 'failed'))
81
+ this.cloud.warm();
82
+ }
83
+ /** Change her voice and speed (from the "她" page); takes effect with the next sentence. */
84
+ setVoice(voice, speed) {
85
+ const switched = serviceOf(voice) !== serviceOf(this.config.voice);
86
+ this.config = { ...this.config, voice, speed };
87
+ if (switched && this.config.enabled) {
88
+ // A new service: what was true of the old one (no key …) says nothing about it.
89
+ this.mark('checking');
90
+ void this.speak('嗯。').catch(() => undefined);
91
+ }
92
+ }
93
+ /** One sentence in a given voice, for trying voices; not cached. Throws with a message for the page. */
94
+ async preview(text, voice, speed) {
95
+ if (!this.config.enabled)
96
+ throw new Error('她的声音已关闭(插件设置里 voice.enabled 是 false)');
97
+ return this.speak(text, voice, speed);
98
+ }
99
+ /** Her sentence as audio, or undefined when she cannot speak right now (it shows as text). */
100
+ async synthesize(text) {
101
+ if (!this.config.enabled || text === '')
102
+ return undefined;
103
+ if ((this.speakingState === 'no-key' || this.speakingState === 'no-quota') && Date.now() - this.keyCheckedAt < KEY_RECHECK_MS)
104
+ return undefined;
105
+ const key = `${this.config.voice}|${this.config.speed}|${text}`;
106
+ const cached = this.cache.get(key);
107
+ if (cached !== undefined)
108
+ return cached;
109
+ try {
110
+ const result = await this.speak(text);
111
+ if ([...text].length <= CACHE_MAX_CHARS) {
112
+ this.cache.set(key, result);
113
+ if (this.cache.size > CACHE_ENTRIES)
114
+ this.cache.delete(this.cache.keys().next().value);
115
+ }
116
+ return result;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ /** What you said, from 16-bit mono PCM. Empty when nothing was understood. */
123
+ async recognize(pcm16, sampleRate) {
124
+ if (this.listening !== 'ready' || this.asr === undefined)
125
+ throw new Error('语音识别还没准备好');
126
+ const view = new DataView(pcm16.buffer, pcm16.byteOffset, pcm16.byteLength);
127
+ const samples = new Float32Array(Math.floor(pcm16.byteLength / 2));
128
+ for (let i = 0; i < samples.length; i++)
129
+ samples[i] = view.getInt16(i * 2, true) / 0x8000;
130
+ const heard = await this.asr.recognize(samples, sampleRate);
131
+ return { text: heard.text, ms: heard.ms };
132
+ }
133
+ stop() {
134
+ this.asr?.stop();
135
+ this.asr = undefined;
136
+ this.cloud.close();
137
+ if (this.listening !== 'off')
138
+ this.listening = 'failed';
139
+ }
140
+ async speak(text, voice = this.config.voice, speed = this.config.speed) {
141
+ try {
142
+ if (serviceOf(voice) === 'edge') {
143
+ if (this.edge.status.status !== 'ready')
144
+ this.mark('installing');
145
+ const audio = await this.edge.synthesize(text, voice, speed);
146
+ this.mark('ready');
147
+ return { audio: audio.audio, durationMs: audio.durationMs, format: 'mp3' };
148
+ }
149
+ const audio = await this.cloud.synthesize(text, voice, speed);
150
+ this.mark('ready');
151
+ return { audio: audio.audio, durationMs: audio.durationMs, format: 'wav' };
152
+ }
153
+ catch (error) {
154
+ this.mark(error instanceof CloudAccountError ? (error.reason === 'quota' ? 'no-quota' : 'no-key') : 'failed', error.message);
155
+ throw error;
156
+ }
157
+ }
158
+ mark(status, error) {
159
+ if (status === 'no-key' || status === 'no-quota')
160
+ this.keyCheckedAt = Date.now();
161
+ if (status !== this.speakingState) {
162
+ const service = serviceOf(this.config.voice);
163
+ if (status === 'ready')
164
+ this.logger.info(`dsh-her: voice ready (${service})`);
165
+ else if (status !== 'checking' && status !== 'installing')
166
+ this.logger.warn(`dsh-her: voice ${status} (${service})${error === undefined ? '' : `: ${error}`}; replies show as text`);
167
+ }
168
+ this.speakingState = status;
169
+ this.speakingError = error;
170
+ }
171
+ }