dsh-her 0.1.0 → 0.2.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.
- package/lib/client.js +862 -506
- package/lib/core/character.js +1 -1
- package/lib/core/index.js +3 -1
- package/lib/core/sounds.js +31 -0
- package/lib/core/spoken-numbers.js +90 -0
- package/lib/core/voices.js +31 -1
- package/lib/dsh/api.js +53 -0
- package/lib/dsh/plugin.js +129 -14
- package/lib/types/core/character.d.ts +1 -1
- package/lib/types/core/index.d.ts +3 -1
- package/lib/types/core/sounds.d.ts +21 -0
- package/lib/types/core/spoken-numbers.d.ts +11 -0
- package/lib/types/core/voices.d.ts +18 -2
- package/lib/types/dsh/api.d.ts +37 -1
- package/lib/types/dsh/plugin.d.ts +2 -0
- package/lib/types/voice/ming-model.d.ts +62 -0
- package/lib/types/voice/ming-voices.d.ts +75 -0
- package/lib/types/voice/ming.d.ts +103 -0
- package/lib/types/voice/speech-queue.d.ts +4 -2
- package/lib/types/voice/uv.d.ts +12 -0
- package/lib/types/voice/voice.d.ts +14 -6
- package/lib/voice/edge.js +4 -48
- package/lib/voice/ming-model.js +30 -0
- package/lib/voice/ming-voices.js +181 -0
- package/lib/voice/ming.js +335 -0
- package/lib/voice/speech-queue.js +5 -3
- package/lib/voice/uv.js +60 -0
- package/lib/voice/voice.js +46 -10
- package/package.json +2 -1
- package/python/ming-requirements.in +1 -0
- package/python/ming-requirements.txt +887 -0
- package/python/ming_worker.py +77 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Her own Ming voices (docs/Ming本地声音设计.md §4), in `<home>/voices/ming/`:
|
|
3
|
+
* `<id>.wav` (the reference recording) and `<id>.json`. Ming says every
|
|
4
|
+
* sentence in such a voice by cloning that recording.
|
|
5
|
+
*
|
|
6
|
+
* - designed: made from a description. The same description gives a
|
|
7
|
+
* slightly different voice every time, so a few candidates are made, one is
|
|
8
|
+
* kept, and from then on she clones it;
|
|
9
|
+
* - cloned: from a recording you add, only with your word that it is your
|
|
10
|
+
* own voice or that you may use it. It never leaves this computer.
|
|
11
|
+
*
|
|
12
|
+
* Candidates not kept yet wait in `drafts/` and are cleared after a day.
|
|
13
|
+
* The voice settings (quality, moods, idle minutes) are in `settings.json`.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
import { wavFromPcm16 } from "../core/index.js";
|
|
23
|
+
const run = promisify(execFile);
|
|
24
|
+
export const DEFAULT_MING_SETTINGS = { quality: 'quality', moods: true };
|
|
25
|
+
export const MING_STEPS = { quality: 10, speed: 5 };
|
|
26
|
+
const DRAFT_TTL_MS = 24 * 3_600_000;
|
|
27
|
+
/** A clone reference: long enough to carry the voice, short enough to keep each sentence quick. */
|
|
28
|
+
export const CLONE_MIN_S = 3;
|
|
29
|
+
export const CLONE_MAX_S = 15;
|
|
30
|
+
export class OwnVoices {
|
|
31
|
+
dir;
|
|
32
|
+
constructor(dir) {
|
|
33
|
+
this.dir = dir;
|
|
34
|
+
}
|
|
35
|
+
list() {
|
|
36
|
+
if (!existsSync(this.dir))
|
|
37
|
+
return [];
|
|
38
|
+
return readdirSync(this.dir)
|
|
39
|
+
.filter(f => f.endsWith('.json') && f !== 'settings.json')
|
|
40
|
+
.flatMap(f => {
|
|
41
|
+
try {
|
|
42
|
+
const voice = JSON.parse(readFileSync(join(this.dir, f), 'utf8'));
|
|
43
|
+
return existsSync(join(this.dir, `${voice.id}.wav`)) ? [voice] : [];
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
50
|
+
}
|
|
51
|
+
/** The recording Ming clones for a voice. */
|
|
52
|
+
ref(id) {
|
|
53
|
+
const voice = this.list().find(v => v.id === id);
|
|
54
|
+
return voice === undefined ? undefined : { wav: join(this.dir, `${voice.id}.wav`), text: voice.text };
|
|
55
|
+
}
|
|
56
|
+
/** Keep a candidate for a while (until it is kept, or a day). */
|
|
57
|
+
addDraft(wav, draft) {
|
|
58
|
+
const drafts = join(this.dir, 'drafts');
|
|
59
|
+
mkdirSync(drafts, { recursive: true });
|
|
60
|
+
for (const f of readdirSync(drafts)) {
|
|
61
|
+
if (Date.now() - statSync(join(drafts, f)).mtimeMs > DRAFT_TTL_MS)
|
|
62
|
+
rmSync(join(drafts, f), { force: true });
|
|
63
|
+
}
|
|
64
|
+
const id = randomUUID();
|
|
65
|
+
writeFileSync(join(drafts, `${id}.wav`), wav);
|
|
66
|
+
writeFileSync(join(drafts, `${id}.json`), JSON.stringify(draft));
|
|
67
|
+
return id;
|
|
68
|
+
}
|
|
69
|
+
/** Keep a candidate as one of her voices. A clone needs your consent and what the recording says. */
|
|
70
|
+
keep(draftId, name, change = {}) {
|
|
71
|
+
if (!/^[0-9a-f-]{36}$/.test(draftId))
|
|
72
|
+
throw new Error('找不到这个试听');
|
|
73
|
+
const drafts = join(this.dir, 'drafts');
|
|
74
|
+
if (!existsSync(join(drafts, `${draftId}.wav`)))
|
|
75
|
+
throw new Error('这个试听已经过期了,再生成一次吧');
|
|
76
|
+
const draft = JSON.parse(readFileSync(join(drafts, `${draftId}.json`), 'utf8'));
|
|
77
|
+
if (draft.kind === 'clone' && change.consent !== true)
|
|
78
|
+
throw new Error('要先确认这是你本人的声音,或者你有授权');
|
|
79
|
+
const text = (change.text ?? draft.text).trim();
|
|
80
|
+
if (text === '')
|
|
81
|
+
throw new Error('要写上录音里说的话');
|
|
82
|
+
if (name.trim() === '')
|
|
83
|
+
throw new Error('给这个声音起个名字');
|
|
84
|
+
const voice = {
|
|
85
|
+
id: `v${Date.now().toString(36)}`, name: name.trim().slice(0, 20), kind: draft.kind, text,
|
|
86
|
+
...(draft.description === undefined ? {} : { description: draft.description }), createdAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
copyFileSync(join(drafts, `${draftId}.wav`), join(this.dir, `${voice.id}.wav`));
|
|
89
|
+
writeFileSync(join(this.dir, `${voice.id}.json`), `${JSON.stringify(voice, null, 2)}\n`);
|
|
90
|
+
return voice;
|
|
91
|
+
}
|
|
92
|
+
remove(id) {
|
|
93
|
+
if (!/^v[0-9a-z]+$/.test(id))
|
|
94
|
+
return;
|
|
95
|
+
rmSync(join(this.dir, `${id}.wav`), { force: true });
|
|
96
|
+
rmSync(join(this.dir, `${id}.json`), { force: true });
|
|
97
|
+
}
|
|
98
|
+
settings() {
|
|
99
|
+
try {
|
|
100
|
+
return { ...DEFAULT_MING_SETTINGS, ...JSON.parse(readFileSync(join(this.dir, 'settings.json'), 'utf8')) };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { ...DEFAULT_MING_SETTINGS };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
saveSettings(patch) {
|
|
107
|
+
const next = { ...this.settings(), ...patch };
|
|
108
|
+
mkdirSync(this.dir, { recursive: true });
|
|
109
|
+
writeFileSync(join(this.dir, 'settings.json'), `${JSON.stringify(next, null, 2)}\n`);
|
|
110
|
+
return next;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A recording you add, made ready to clone: converted by macOS's afconvert
|
|
115
|
+
* (wav, m4a from Voice Memos, mp3, aiff …) to 44.1 kHz mono, quiet start and
|
|
116
|
+
* end cut off, at most CLONE_MAX_S. Also at 16 kHz, to recognize what it says.
|
|
117
|
+
*/
|
|
118
|
+
export async function prepareRecording(bytes, name) {
|
|
119
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-her-clone-'));
|
|
120
|
+
try {
|
|
121
|
+
const ext = /\.([a-z0-9]{1,5})$/i.exec(name)?.[1] ?? 'wav';
|
|
122
|
+
const input = join(dir, `in.${ext}`);
|
|
123
|
+
await writeFile(input, bytes);
|
|
124
|
+
const full = join(dir, 'full.wav');
|
|
125
|
+
try {
|
|
126
|
+
await run('afconvert', ['-f', 'WAVE', '-d', 'LEI16@44100', '-c', '1', input, full], { timeout: 60_000 });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error('读不了这个录音文件(试试 wav、m4a 或 mp3)');
|
|
130
|
+
}
|
|
131
|
+
const samples = trimSilence(pcmOf(new Uint8Array(await readFile(full))), 44_100);
|
|
132
|
+
const seconds = samples.length / 44_100;
|
|
133
|
+
if (seconds < CLONE_MIN_S)
|
|
134
|
+
throw new Error(`录音太短了(${seconds.toFixed(1)} 秒),至少要 ${CLONE_MIN_S} 秒清楚的说话`);
|
|
135
|
+
const clip = samples.subarray(0, CLONE_MAX_S * 44_100);
|
|
136
|
+
const pcm = new Uint8Array(clip.buffer, clip.byteOffset, clip.byteLength);
|
|
137
|
+
const wav = wavFromPcm16(pcm, 44_100);
|
|
138
|
+
const trimmed = join(dir, 'trimmed.wav');
|
|
139
|
+
await writeFile(trimmed, wav);
|
|
140
|
+
const small = join(dir, 'small.wav');
|
|
141
|
+
await run('afconvert', ['-f', 'WAVE', '-d', 'LEI16@16000', '-c', '1', trimmed, small], { timeout: 60_000 });
|
|
142
|
+
const pcm16 = pcmOf(new Uint8Array(await readFile(small)));
|
|
143
|
+
return { wav, pcm16k: new Uint8Array(pcm16.buffer, pcm16.byteOffset, pcm16.byteLength), seconds: clip.length / 44_100 };
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await rm(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The 16-bit samples of a WAV file (its `data` chunk). */
|
|
150
|
+
function pcmOf(wav) {
|
|
151
|
+
const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength);
|
|
152
|
+
let at = 12;
|
|
153
|
+
while (at + 8 <= wav.byteLength) {
|
|
154
|
+
const id = String.fromCharCode(...wav.subarray(at, at + 4));
|
|
155
|
+
const size = view.getUint32(at + 4, true);
|
|
156
|
+
if (id === 'data') {
|
|
157
|
+
const bytes = wav.slice(at + 8, at + 8 + Math.min(size, wav.byteLength - at - 8));
|
|
158
|
+
return new Int16Array(bytes.buffer, 0, Math.floor(bytes.byteLength / 2));
|
|
159
|
+
}
|
|
160
|
+
at += 8 + size + (size % 2);
|
|
161
|
+
}
|
|
162
|
+
throw new Error('录音文件里没有声音');
|
|
163
|
+
}
|
|
164
|
+
/** Cut quiet 10 ms frames off both ends, keeping 0.1 s around the speech. */
|
|
165
|
+
export function trimSilence(samples, rate) {
|
|
166
|
+
const frame = Math.round(rate / 100);
|
|
167
|
+
const loud = (i) => {
|
|
168
|
+
let sum = 0;
|
|
169
|
+
for (let j = i; j < Math.min(i + frame, samples.length); j++)
|
|
170
|
+
sum += samples[j] * samples[j];
|
|
171
|
+
return Math.sqrt(sum / frame) > 0.01 * 32768;
|
|
172
|
+
};
|
|
173
|
+
let start = 0;
|
|
174
|
+
while (start < samples.length && !loud(start))
|
|
175
|
+
start += frame;
|
|
176
|
+
let end = samples.length - frame;
|
|
177
|
+
while (end > start && !loud(end))
|
|
178
|
+
end -= frame;
|
|
179
|
+
const pad = rate / 10;
|
|
180
|
+
return samples.subarray(Math.max(0, start - pad), Math.min(samples.length, end + frame + pad));
|
|
181
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ming-3.0-tts on this computer (docs/Ming本地声音设计.md): offline, free,
|
|
3
|
+
* with moods, and voices of your own. Apple chips with macOS 14+ only (MLX).
|
|
4
|
+
*
|
|
5
|
+
* - Install (on request, from the "她" page): uv (voice/uv.ts) builds a
|
|
6
|
+
* Python 3.12 environment in `<home>/ming/env` and installs mlx-audio from
|
|
7
|
+
* python/ming-requirements.txt, every package pinned with its hash; the
|
|
8
|
+
* model (voice/ming-model.ts) is downloaded into `<home>/ming/model`, each
|
|
9
|
+
* file checked against its size and SHA-256. An interrupted download goes
|
|
10
|
+
* on where it stopped. Deleting `<home>/ming` uninstalls it.
|
|
11
|
+
* - Run: python/ming_worker.py, one process that keeps the model loaded
|
|
12
|
+
* (about 2 GB of memory); sentences go to it one at a time. It starts with
|
|
13
|
+
* the first sentence (or when you start talking, `warm`), and stops after
|
|
14
|
+
* some minutes without a sentence to give the memory back.
|
|
15
|
+
* - A worker that dies is started again with the next sentence; one that dies
|
|
16
|
+
* twice within five minutes is left stopped (`failed`) until `restart`.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { release, totalmem } from 'node:os';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { createInterface } from 'node:readline';
|
|
24
|
+
import { Readable, Transform } from 'node:stream';
|
|
25
|
+
import { pipeline } from 'node:stream/promises';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { wavFromPcm16 } from "../core/index.js";
|
|
28
|
+
import { MING_MODEL, MING_MODEL_BYTES } from "./ming-model.js";
|
|
29
|
+
import { installUv, run, uvEnv } from "./uv.js";
|
|
30
|
+
const PYTHON_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'python');
|
|
31
|
+
const WORKER = join(PYTHON_DIR, 'ming_worker.py');
|
|
32
|
+
const REQUIREMENTS = join(PYTHON_DIR, 'ming-requirements.txt');
|
|
33
|
+
const LOAD_TIMEOUT_MS = 180_000;
|
|
34
|
+
const SPEAK_TIMEOUT_MS = 90_000;
|
|
35
|
+
const CRASH_WINDOW_MS = 5 * 60_000;
|
|
36
|
+
/** Why Ming cannot run here, or undefined when it can: Apple chips and macOS 14 (Darwin 23) or later. */
|
|
37
|
+
export function mingUnsupported(platform = process.platform, arch = process.arch, darwin = release()) {
|
|
38
|
+
if (platform !== 'darwin' || arch !== 'arm64')
|
|
39
|
+
return '本机 Ming 只支持 Apple 芯片的 Mac';
|
|
40
|
+
if (Number.parseInt(darwin, 10) < 23)
|
|
41
|
+
return '本机 Ming 要 macOS 14 或更新';
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Minutes without a sentence before the worker stops: 10 on a Mac with 16 GB or less, else 30. */
|
|
45
|
+
export const defaultIdleMinutes = (memory = totalmem()) => (memory <= 16 * 2 ** 30 ? 10 : 30);
|
|
46
|
+
export class MingVoice {
|
|
47
|
+
root;
|
|
48
|
+
uvHome;
|
|
49
|
+
logger;
|
|
50
|
+
state;
|
|
51
|
+
worker;
|
|
52
|
+
starting;
|
|
53
|
+
installing;
|
|
54
|
+
pending = new Map();
|
|
55
|
+
queue = Promise.resolve();
|
|
56
|
+
nextId = 1;
|
|
57
|
+
lastUse = 0;
|
|
58
|
+
crashes = [];
|
|
59
|
+
stderr = [];
|
|
60
|
+
idleMinutes;
|
|
61
|
+
idleTimer;
|
|
62
|
+
/** `root`: `<home>/ming`; `uvHome`: `<home>/uv`, shared with the Edge voice. `idleMinutes` 0: never stop. */
|
|
63
|
+
constructor(root, uvHome, logger, idleMinutes = defaultIdleMinutes()) {
|
|
64
|
+
this.root = root;
|
|
65
|
+
this.uvHome = uvHome;
|
|
66
|
+
this.logger = logger;
|
|
67
|
+
this.idleMinutes = idleMinutes;
|
|
68
|
+
const unsupported = mingUnsupported();
|
|
69
|
+
this.state = unsupported !== undefined ? { state: 'unsupported', error: unsupported } : { state: this.installed ? 'stopped' : 'absent' };
|
|
70
|
+
this.idleTimer = setInterval(() => this.stopWhenIdle(), 60_000);
|
|
71
|
+
this.idleTimer.unref();
|
|
72
|
+
}
|
|
73
|
+
get status() {
|
|
74
|
+
return this.state;
|
|
75
|
+
}
|
|
76
|
+
get installed() {
|
|
77
|
+
return existsSync(this.python) && existsSync(join(this.modelDir, '.verified'));
|
|
78
|
+
}
|
|
79
|
+
setIdleMinutes(minutes) {
|
|
80
|
+
this.idleMinutes = minutes;
|
|
81
|
+
}
|
|
82
|
+
get python() {
|
|
83
|
+
return join(this.root, 'env', 'bin', 'python3');
|
|
84
|
+
}
|
|
85
|
+
get modelDir() {
|
|
86
|
+
return join(this.root, 'model');
|
|
87
|
+
}
|
|
88
|
+
/** Install everything (once; a second call joins the first). Progress shows in `status`. */
|
|
89
|
+
install() {
|
|
90
|
+
if (this.state.state === 'unsupported')
|
|
91
|
+
return Promise.reject(new Error(this.state.error));
|
|
92
|
+
this.installing ??= this.doInstall().finally(() => { this.installing = undefined; });
|
|
93
|
+
return this.installing;
|
|
94
|
+
}
|
|
95
|
+
async doInstall() {
|
|
96
|
+
const step = (text, extra = {}) => { this.state = { state: 'installing', step: text, ...extra }; };
|
|
97
|
+
try {
|
|
98
|
+
step('准备 Python(第一次约 1 分钟)');
|
|
99
|
+
const env = uvEnv(this.uvHome);
|
|
100
|
+
const uv = await installUv(this.uvHome);
|
|
101
|
+
const envDir = join(this.root, 'env');
|
|
102
|
+
if (!existsSync(this.python))
|
|
103
|
+
await run(uv, ['venv', '--quiet', '--python', '3.12', envDir], 600_000, env);
|
|
104
|
+
step('安装语音程序(约 450 MB)');
|
|
105
|
+
await run(uv, ['pip', 'install', '--quiet', '--require-hashes', '--python', this.python, '-r', REQUIREMENTS], 1_200_000, env);
|
|
106
|
+
// Installed now: the downloads are not needed any more.
|
|
107
|
+
rmSync(env.UV_CACHE_DIR, { recursive: true, force: true });
|
|
108
|
+
await this.downloadModel((done, total) => step('下载声音模型(约 1.6 GB)', { done, total }));
|
|
109
|
+
this.state = { state: 'stopped' };
|
|
110
|
+
this.logger.info('dsh-her: Ming voice installed');
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
this.state = { state: 'failed', error: `安装本机 Ming 失败:${error.message}` };
|
|
114
|
+
throw new Error(this.state.error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Every model file, checked; files already checked (listed in `.verified`) are kept. */
|
|
118
|
+
async downloadModel(onProgress) {
|
|
119
|
+
mkdirSync(this.modelDir, { recursive: true });
|
|
120
|
+
const marker = join(this.modelDir, '.verified');
|
|
121
|
+
if (existsSync(marker))
|
|
122
|
+
return;
|
|
123
|
+
const checkedFile = join(this.modelDir, '.checked.json');
|
|
124
|
+
const checked = new Set(existsSync(checkedFile) ? JSON.parse(readFileSync(checkedFile, 'utf8')) : []);
|
|
125
|
+
let done = 0;
|
|
126
|
+
for (const file of MING_MODEL.files) {
|
|
127
|
+
const target = join(this.modelDir, file.name);
|
|
128
|
+
if (checked.has(file.sha256) && existsSync(target) && statSync(target).size === file.size) {
|
|
129
|
+
done += file.size;
|
|
130
|
+
onProgress(done, MING_MODEL_BYTES);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const before = done;
|
|
134
|
+
let lastError;
|
|
135
|
+
for (const source of MING_MODEL.sources) {
|
|
136
|
+
try {
|
|
137
|
+
await download(`${source}${file.name}`, target, file, got => onProgress(before + got, MING_MODEL_BYTES));
|
|
138
|
+
lastError = undefined;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
lastError = error;
|
|
143
|
+
this.logger.warn(`dsh-her: Ming model ${file.name} from ${source} failed (${lastError.message})`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (lastError !== undefined)
|
|
147
|
+
throw new Error(`下载 ${file.name} 失败:${lastError.message}`);
|
|
148
|
+
done = before + file.size;
|
|
149
|
+
checked.add(file.sha256);
|
|
150
|
+
writeFileSync(checkedFile, JSON.stringify([...checked]));
|
|
151
|
+
}
|
|
152
|
+
writeFileSync(marker, `${MING_MODEL.repo}@${MING_MODEL.revision}\n`);
|
|
153
|
+
rmSync(checkedFile, { force: true });
|
|
154
|
+
}
|
|
155
|
+
/** Stop and delete everything (about 2 GB). */
|
|
156
|
+
uninstall() {
|
|
157
|
+
this.stop();
|
|
158
|
+
rmSync(this.root, { recursive: true, force: true });
|
|
159
|
+
if (this.state.state !== 'unsupported')
|
|
160
|
+
this.state = { state: 'absent' };
|
|
161
|
+
}
|
|
162
|
+
/** You started talking: have the model loaded by the time she answers. */
|
|
163
|
+
warm() {
|
|
164
|
+
if (this.installed && this.state.state === 'stopped')
|
|
165
|
+
void this.ensure().catch(() => undefined);
|
|
166
|
+
}
|
|
167
|
+
/** After `failed`: forget the crashes and start again. */
|
|
168
|
+
async restart() {
|
|
169
|
+
this.stop();
|
|
170
|
+
this.crashes = [];
|
|
171
|
+
if (this.installed)
|
|
172
|
+
this.state = { state: 'stopped' };
|
|
173
|
+
await this.ensure();
|
|
174
|
+
}
|
|
175
|
+
/** One sentence. Sentences are said one at a time, in the order asked. */
|
|
176
|
+
synthesize(request) {
|
|
177
|
+
const job = this.queue.then(() => this.speakNow(request));
|
|
178
|
+
this.queue = job.catch(() => undefined);
|
|
179
|
+
return job;
|
|
180
|
+
}
|
|
181
|
+
stop() {
|
|
182
|
+
const worker = this.worker;
|
|
183
|
+
this.worker = undefined;
|
|
184
|
+
this.starting = undefined;
|
|
185
|
+
if (worker !== undefined) {
|
|
186
|
+
worker.removeAllListeners('exit');
|
|
187
|
+
worker.kill();
|
|
188
|
+
this.failPending(new Error('本机 Ming 已停止'));
|
|
189
|
+
}
|
|
190
|
+
if (this.state.state === 'ready' || this.state.state === 'loading')
|
|
191
|
+
this.state = { state: 'stopped' };
|
|
192
|
+
}
|
|
193
|
+
/** Stop for good (the plugin is unloading). */
|
|
194
|
+
close() {
|
|
195
|
+
clearInterval(this.idleTimer);
|
|
196
|
+
this.stop();
|
|
197
|
+
}
|
|
198
|
+
async speakNow(request) {
|
|
199
|
+
await this.ensure();
|
|
200
|
+
this.lastUse = Date.now();
|
|
201
|
+
const reply = await this.send({ op: 'speak', ...request }, SPEAK_TIMEOUT_MS);
|
|
202
|
+
this.lastUse = Date.now();
|
|
203
|
+
const pcm = Buffer.from(String(reply.pcm16), 'base64');
|
|
204
|
+
const sampleRate = Number(reply.sampleRate);
|
|
205
|
+
return { audio: wavFromPcm16(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength), sampleRate), durationMs: Math.round(pcm.byteLength / 2 / sampleRate * 1000), ms: Number(reply.ms) };
|
|
206
|
+
}
|
|
207
|
+
ensure() {
|
|
208
|
+
if (this.state.state === 'ready' && this.worker !== undefined)
|
|
209
|
+
return Promise.resolve();
|
|
210
|
+
if (this.state.state === 'unsupported')
|
|
211
|
+
return Promise.reject(new Error(this.state.error));
|
|
212
|
+
if (this.state.state === 'installing')
|
|
213
|
+
return Promise.reject(new Error('本机 Ming 正在安装'));
|
|
214
|
+
if (!this.installed)
|
|
215
|
+
return Promise.reject(new Error('本机 Ming 还没安装'));
|
|
216
|
+
if (this.state.state === 'failed' && this.crashes.length >= 2)
|
|
217
|
+
return Promise.reject(new Error(this.state.error ?? '本机 Ming 出错了'));
|
|
218
|
+
this.starting ??= this.start().finally(() => { this.starting = undefined; });
|
|
219
|
+
return this.starting;
|
|
220
|
+
}
|
|
221
|
+
async start() {
|
|
222
|
+
this.state = { state: 'loading' };
|
|
223
|
+
this.stderr = [];
|
|
224
|
+
const worker = spawn(this.python, ['-I', WORKER], {
|
|
225
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
226
|
+
env: { ...process.env, HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1', TOKENIZERS_PARALLELISM: 'false', PYTHONUNBUFFERED: '1' },
|
|
227
|
+
});
|
|
228
|
+
this.worker = worker;
|
|
229
|
+
createInterface({ input: worker.stdout }).on('line', line => this.onReply(line));
|
|
230
|
+
createInterface({ input: worker.stderr }).on('line', line => {
|
|
231
|
+
this.stderr.push(line);
|
|
232
|
+
if (this.stderr.length > 20)
|
|
233
|
+
this.stderr.shift();
|
|
234
|
+
});
|
|
235
|
+
worker.on('error', error => this.onExit(worker, error.message));
|
|
236
|
+
worker.on('exit', code => this.onExit(worker, `退出码 ${code}`));
|
|
237
|
+
try {
|
|
238
|
+
const reply = await this.send({ op: 'load', model: this.modelDir }, LOAD_TIMEOUT_MS);
|
|
239
|
+
this.state = { state: 'ready' };
|
|
240
|
+
this.lastUse = Date.now();
|
|
241
|
+
this.logger.info(`dsh-her: Ming voice loaded (${String(reply.loadMs)} ms)`);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
this.stop();
|
|
245
|
+
this.state = { state: 'failed', error: `本机 Ming 启动失败:${error.message}` };
|
|
246
|
+
this.crashes.push(Date.now());
|
|
247
|
+
throw new Error(this.state.error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
send(message, timeoutMs) {
|
|
251
|
+
const worker = this.worker;
|
|
252
|
+
if (worker?.stdin === null || worker === undefined)
|
|
253
|
+
return Promise.reject(new Error('本机 Ming 没有在运行'));
|
|
254
|
+
const id = this.nextId++;
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
this.pending.delete(id);
|
|
258
|
+
reject(new Error('本机 Ming 太久没有回应'));
|
|
259
|
+
// A worker stuck on one sentence is stuck for all of them.
|
|
260
|
+
this.stop();
|
|
261
|
+
}, timeoutMs);
|
|
262
|
+
this.pending.set(id, {
|
|
263
|
+
resolve: reply => { clearTimeout(timer); resolve(reply); },
|
|
264
|
+
reject: error => { clearTimeout(timer); reject(error); },
|
|
265
|
+
});
|
|
266
|
+
worker.stdin.write(`${JSON.stringify({ ...message, id })}\n`);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
onReply(line) {
|
|
270
|
+
let reply;
|
|
271
|
+
try {
|
|
272
|
+
reply = JSON.parse(line);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const waiting = this.pending.get(Number(reply.id));
|
|
278
|
+
if (waiting === undefined)
|
|
279
|
+
return;
|
|
280
|
+
this.pending.delete(Number(reply.id));
|
|
281
|
+
if (reply.ok === true)
|
|
282
|
+
waiting.resolve(reply);
|
|
283
|
+
else
|
|
284
|
+
waiting.reject(new Error(String(reply.error ?? '未知错误')));
|
|
285
|
+
}
|
|
286
|
+
onExit(worker, why) {
|
|
287
|
+
if (worker !== this.worker)
|
|
288
|
+
return;
|
|
289
|
+
this.worker = undefined;
|
|
290
|
+
const detail = this.stderr.slice(-3).join(' ').slice(-300);
|
|
291
|
+
this.failPending(new Error(`本机 Ming 意外退出(${why})${detail === '' ? '' : `:${detail}`}`));
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
this.crashes = [...this.crashes.filter(at => now - at < CRASH_WINDOW_MS), now];
|
|
294
|
+
this.state = this.crashes.length >= 2
|
|
295
|
+
? { state: 'failed', error: `本机 Ming 连续出错(${why})${detail === '' ? '' : `:${detail}`}` }
|
|
296
|
+
: { state: 'stopped' };
|
|
297
|
+
this.logger.warn(`dsh-her: Ming voice exited (${why}) ${detail}`);
|
|
298
|
+
}
|
|
299
|
+
failPending(error) {
|
|
300
|
+
for (const waiting of this.pending.values())
|
|
301
|
+
waiting.reject(error);
|
|
302
|
+
this.pending.clear();
|
|
303
|
+
}
|
|
304
|
+
stopWhenIdle() {
|
|
305
|
+
if (this.idleMinutes <= 0 || this.state.state !== 'ready' || this.pending.size > 0)
|
|
306
|
+
return;
|
|
307
|
+
if (Date.now() - this.lastUse < this.idleMinutes * 60_000)
|
|
308
|
+
return;
|
|
309
|
+
this.logger.info(`dsh-her: Ming voice idle for ${this.idleMinutes} min, stopped to free memory`);
|
|
310
|
+
this.stop();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** One file from `url` into `target`, checked against its size and SHA-256 before it takes the file's place. */
|
|
314
|
+
async function download(url, target, file, onProgress) {
|
|
315
|
+
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(3_600_000) });
|
|
316
|
+
if (!res.ok || res.body === null)
|
|
317
|
+
throw new Error(`HTTP ${res.status}`);
|
|
318
|
+
const hash = createHash('sha256');
|
|
319
|
+
let got = 0;
|
|
320
|
+
const count = new Transform({
|
|
321
|
+
transform(chunk, _encoding, done) {
|
|
322
|
+
hash.update(chunk);
|
|
323
|
+
got += chunk.length;
|
|
324
|
+
onProgress(got);
|
|
325
|
+
done(null, chunk);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
const part = `${target}.part`;
|
|
329
|
+
await pipeline(Readable.fromWeb(res.body), count, createWriteStream(part));
|
|
330
|
+
if (got !== file.size || hash.digest('hex') !== file.sha256) {
|
|
331
|
+
rmSync(part, { force: true });
|
|
332
|
+
throw new Error('下载的文件校验不对');
|
|
333
|
+
}
|
|
334
|
+
renameSync(part, target);
|
|
335
|
+
}
|
|
@@ -22,14 +22,16 @@ export class SpeechQueue {
|
|
|
22
22
|
sent = new Map();
|
|
23
23
|
synth;
|
|
24
24
|
out;
|
|
25
|
+
/** `synth` gets what to say and the sentence's cues (its emotion colours the voice). */
|
|
25
26
|
constructor(synth, out) {
|
|
26
27
|
this.synth = synth;
|
|
27
28
|
this.out = out;
|
|
28
29
|
}
|
|
29
|
-
say
|
|
30
|
+
/** `spoken`: what to say when it differs from the subtitle `text` (sound markers for Ming). */
|
|
31
|
+
say(uid, seq, text, cues = [], spoken) {
|
|
30
32
|
if (this.cancelled.has(uid))
|
|
31
33
|
return;
|
|
32
|
-
this.jobs.push({ uid, kind: 'sentence', seq, text, cues });
|
|
34
|
+
this.jobs.push({ uid, kind: 'sentence', seq, text, cues, ...(spoken === undefined ? {} : { spoken }) });
|
|
33
35
|
this.startAhead();
|
|
34
36
|
void this.pump();
|
|
35
37
|
}
|
|
@@ -65,7 +67,7 @@ export class SpeechQueue {
|
|
|
65
67
|
continue;
|
|
66
68
|
if (++sentences > AHEAD)
|
|
67
69
|
break;
|
|
68
|
-
job.audio ??= this.synth(job.text).catch(() => undefined);
|
|
70
|
+
job.audio ??= this.synth(job.spoken ?? job.text, job.cues).catch(() => undefined);
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
async pump() {
|
package/lib/voice/uv.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running Python tools without asking the computer for a Python: uv
|
|
3
|
+
* (https://github.com/astral-sh/uv), one pinned, checksummed download kept
|
|
4
|
+
* in `<home>/uv`, fetches a Python of its own into the same folder and builds
|
|
5
|
+
* virtual environments with it. No dialogs, nothing system-wide. Used by the
|
|
6
|
+
* Edge voice (when the computer has no usable Python) and the Ming voice.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
export function run(cmd, args, timeout, env) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
execFile(cmd, args, { timeout, maxBuffer: 8 << 20, ...(env === undefined ? {} : { env: { ...process.env, ...env } }) }, (error, stdout, stderr) => {
|
|
16
|
+
if (error !== null)
|
|
17
|
+
reject(new Error(String(stderr || error.message).trim().split('\n').slice(-2).join(' ')));
|
|
18
|
+
else
|
|
19
|
+
resolve(String(stdout));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** What uv needs to keep its Python and downloads inside `uvHome`. */
|
|
24
|
+
export function uvEnv(uvHome) {
|
|
25
|
+
return { UV_PYTHON_INSTALL_DIR: join(uvHome, 'python'), UV_CACHE_DIR: join(uvHome, 'cache'), UV_NO_CONFIG: '1' };
|
|
26
|
+
}
|
|
27
|
+
/** uv, pinned: version and the SHA-256 of each download (from the release's .sha256 files). */
|
|
28
|
+
const UV_VERSION = '0.11.26';
|
|
29
|
+
const UV_ASSETS = {
|
|
30
|
+
'darwin-arm64': { file: 'uv-aarch64-apple-darwin.tar.gz', sha256: '8f7fbf1708399b921857bce71e1d60f0d3ccf52a30caebc1c1a2f175dce13ab6' },
|
|
31
|
+
'darwin-x64': { file: 'uv-x86_64-apple-darwin.tar.gz', sha256: '922b460202707dd5f4ccacbadbe7f6a546cc46e82a99bf50ca99a7977a78eddd' },
|
|
32
|
+
'win32-x64': { file: 'uv-x86_64-pc-windows-msvc.zip', sha256: '4e1278ede866be6c0bf32d2f466cc6de7a9fb399ecf20c9ce2d186e52424be47' },
|
|
33
|
+
};
|
|
34
|
+
/** The uv binary in `uvHome`, downloaded and checked the first time. */
|
|
35
|
+
export async function installUv(uvHome) {
|
|
36
|
+
const key = `${process.platform}-${process.arch}`;
|
|
37
|
+
const asset = UV_ASSETS[key];
|
|
38
|
+
if (asset === undefined)
|
|
39
|
+
throw new Error(`这台电脑(${key})没有 Python,也没有现成的办法自动装:请先装 Python 3`);
|
|
40
|
+
const dir = join(uvHome, UV_VERSION);
|
|
41
|
+
const found = [join(dir, 'uv'), join(dir, 'uv.exe'), join(dir, asset.file.replace(/\.(tar\.gz|zip)$/, ''), 'uv')].find(p => existsSync(p));
|
|
42
|
+
if (found !== undefined)
|
|
43
|
+
return found;
|
|
44
|
+
const res = await fetch(`https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${asset.file}`, { redirect: 'follow', signal: AbortSignal.timeout(300_000) });
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
throw new Error(`下载 uv 失败(${res.status})`);
|
|
47
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
48
|
+
if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256)
|
|
49
|
+
throw new Error('下载的 uv 校验不对,没有使用');
|
|
50
|
+
await mkdir(dir, { recursive: true });
|
|
51
|
+
const archive = join(dir, asset.file);
|
|
52
|
+
await writeFile(archive, bytes);
|
|
53
|
+
// tar reads both the .tar.gz and (Windows 10+) the .zip.
|
|
54
|
+
await run('tar', ['-xf', archive, '-C', dir], 120_000);
|
|
55
|
+
await rm(archive, { force: true });
|
|
56
|
+
const uv = [join(dir, 'uv'), join(dir, 'uv.exe'), join(dir, asset.file.replace(/\.(tar\.gz|zip)$/, ''), 'uv')].find(p => existsSync(p));
|
|
57
|
+
if (uv === undefined)
|
|
58
|
+
throw new Error('uv 解压后找不到');
|
|
59
|
+
return uv;
|
|
60
|
+
}
|