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
package/lib/core/character.js
CHANGED
|
@@ -40,7 +40,7 @@ export function invalidCharacter(c) {
|
|
|
40
40
|
return '人设太长了(最多 3000 字)';
|
|
41
41
|
if ([...c.callYou].length > 20)
|
|
42
42
|
return '称呼最多 20 个字';
|
|
43
|
-
if (!/^[A-Za-z0-9_-]{1,100}$/.test(c.voice.id))
|
|
43
|
+
if (!/^(?:[A-Za-z0-9_-]{1,100}|ming:[\u4e00-\u9fff_]{1,20}|ming-own:v[0-9a-z]{1,20})$/.test(c.voice.id))
|
|
44
44
|
return '音色不对';
|
|
45
45
|
if (!(c.voice.speed >= 0.5 && c.voice.speed <= 2))
|
|
46
46
|
return '语速要在 0.5 到 2 之间';
|
package/lib/core/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export { judgeBargeIn } from "./echo.js";
|
|
|
5
5
|
export { EMOTION_WORDS, extractCues, isHeavy, performanceSection } from "./cues.js";
|
|
6
6
|
export { buildSystemPrompt, memoryContext, SAMPLE_PERSONA } from "./prompt.js";
|
|
7
7
|
export { herPaths } from "./paths.js";
|
|
8
|
-
export { DEFAULT_VOICE, SERVICE_NAMES, serviceOf, VOICE_MODEL, VOICES, voiceName } from "./voices.js";
|
|
8
|
+
export { DEFAULT_VOICE, MING_MOODS, mingSpeech, OWN_PREFIX, SERVICE_NAMES, serviceOf, VOICE_MODEL, VOICES, voiceName } from "./voices.js";
|
|
9
|
+
export { extractSounds, SOUND_SECTION, withSounds } from "./sounds.js";
|
|
10
|
+
export { spokenNumbers } from "./spoken-numbers.js";
|
|
9
11
|
export { invalidCharacter, loadCharacter, saveCharacter } from "./character.js";
|
|
10
12
|
export { decodeWav, encodeWav, wavFromPcm16 } from "./wav.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sounds she can make with the Ming voice (docs/Ming本地声音设计.md §5): she
|
|
3
|
+
* writes 〔叹气〕 or 〔笑〕 in a sentence; the tag is taken out of the subtitle
|
|
4
|
+
* and Ming's own marker ([Sigh], [Laughter]) goes at the start or the end of
|
|
5
|
+
* what is spoken. A marker in the middle of a sentence swallows the word next
|
|
6
|
+
* to it, so it is moved to whichever end the tag was nearer.
|
|
7
|
+
*
|
|
8
|
+
* With another voice the tags are simply dropped (extractCues removes every
|
|
9
|
+
* tag it does not know), and her prompt never mentions them.
|
|
10
|
+
*/
|
|
11
|
+
const SOUNDS = { 叹气: '[Sigh]', 笑: '[Laughter]' };
|
|
12
|
+
const SOUND_TAG = /〔\s*(叹气|笑)\s*〕/g;
|
|
13
|
+
export function extractSounds(text) {
|
|
14
|
+
const before = [];
|
|
15
|
+
const after = [];
|
|
16
|
+
const length = [...text.replace(SOUND_TAG, '')].length;
|
|
17
|
+
let at = 0;
|
|
18
|
+
const clean = text.replace(SOUND_TAG, (whole, name, offset) => {
|
|
19
|
+
const position = [...text.slice(0, offset)].length - at;
|
|
20
|
+
at += [...whole].length;
|
|
21
|
+
(position * 2 <= length ? before : after).push(SOUNDS[name]);
|
|
22
|
+
return '';
|
|
23
|
+
});
|
|
24
|
+
return { text: clean, before, after };
|
|
25
|
+
}
|
|
26
|
+
/** What Ming says: the sentence with its sound markers at the ends. */
|
|
27
|
+
export function withSounds(spoken, sounds) {
|
|
28
|
+
return [...sounds.before, spoken, ...sounds.after].filter(part => part !== '').join(' ');
|
|
29
|
+
}
|
|
30
|
+
/** The lines of her prompt that teach the sounds (only while she speaks with Ming). */
|
|
31
|
+
export const SOUND_SECTION = '你的声音能真的叹气和笑出声:在句子开头或结尾写〔叹气〕或〔笑〕(不会显示)。只在真的想叹气、笑出来时用,一次回复最多一次。';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Digits and a few symbols written out the way they are said, for the Ming
|
|
3
|
+
* voice, which reads "2个" as "二个" (its MLX port has no text
|
|
4
|
+
* normalization). Only what turns up in her chat: counts, times, dates,
|
|
5
|
+
* years, percentages, decimals, phone numbers and codes, ranges, degrees.
|
|
6
|
+
*/
|
|
7
|
+
const DIGITS = '零一二三四五六七八九';
|
|
8
|
+
const SMALL_UNITS = ['', '十', '百', '千'];
|
|
9
|
+
const BIG_UNITS = ['', '万', '亿'];
|
|
10
|
+
/** Measure words after which 2 is 两 (两个, 两点, 两次 …). */
|
|
11
|
+
const MEASURE = /^(?:个|位|点|次|天|只|本|件|张|条|岁|种|遍|杯|分钟|小时|周|斤|块|台|辆|家|句|下|口|双|对|份|层|片|场|首|部|回|趟|步|顿|碗|瓶|盒|袋|根|颗|粒|间|座|所|名|人|样|批|年|星期|礼拜|倍)/;
|
|
12
|
+
/** Words before a number that is read digit by digit. */
|
|
13
|
+
const DIGIT_CONTEXT = /(?:验证码|号码|电话|手机|编号|尾号|房间|门牌|邮编|工号|学号|密码|单号)[是为::\s]*$/;
|
|
14
|
+
/** "2026" → 二零二六. */
|
|
15
|
+
export function readDigits(digits) {
|
|
16
|
+
return [...digits].map(d => DIGITS[Number(d)]).join('');
|
|
17
|
+
}
|
|
18
|
+
/** "1205" → 一千二百零五; "12" → 十二; "20000" → 两万. Longer than 12 digits: one by one. */
|
|
19
|
+
export function readInteger(number) {
|
|
20
|
+
const digits = number.replace(/^0+(?=\d)/, '');
|
|
21
|
+
if (digits.length > 12)
|
|
22
|
+
return readDigits(digits);
|
|
23
|
+
if (/^0+$/.test(digits))
|
|
24
|
+
return '零';
|
|
25
|
+
const groups = [];
|
|
26
|
+
for (let end = digits.length; end > 0; end -= 4)
|
|
27
|
+
groups.unshift(Number(digits.slice(Math.max(0, end - 4), end)));
|
|
28
|
+
let out = '';
|
|
29
|
+
let zero = false;
|
|
30
|
+
groups.forEach((group, i) => {
|
|
31
|
+
const big = BIG_UNITS[groups.length - 1 - i];
|
|
32
|
+
if (group === 0) {
|
|
33
|
+
zero = out !== '';
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (out !== '' && (zero || group < 1000))
|
|
37
|
+
out += '零';
|
|
38
|
+
zero = false;
|
|
39
|
+
out += readGroup(group) + big;
|
|
40
|
+
});
|
|
41
|
+
return out.replace(/^一十/, '十').replace(/^二(?=[百千万亿])/, '两');
|
|
42
|
+
}
|
|
43
|
+
function readGroup(group) {
|
|
44
|
+
let out = '';
|
|
45
|
+
let zero = false;
|
|
46
|
+
for (let pos = 3; pos >= 0; pos--) {
|
|
47
|
+
const d = Math.floor(group / 10 ** pos) % 10;
|
|
48
|
+
if (d === 0) {
|
|
49
|
+
zero = out !== '';
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (zero)
|
|
53
|
+
out += '零';
|
|
54
|
+
zero = false;
|
|
55
|
+
out += DIGITS[d] + SMALL_UNITS[pos];
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function spokenNumbers(text) {
|
|
60
|
+
return text
|
|
61
|
+
// 30% → 百分之三十; 2.5% → 百分之二点五
|
|
62
|
+
.replace(/(\d+(?:\.\d+)?)\s*[%%]/g, (_w, n) => `百分之${readNumber(n)}`)
|
|
63
|
+
// 15:30 → 十五点三十分; 9:05 → 九点零五分; 8:00 → 八点
|
|
64
|
+
.replace(/(\d{1,2})[::](\d{2})(?!\d)/g, (_w, h, m) => `${hour(h)}点${m === '00' ? '' : `${m.startsWith('0') ? '零' : ''}${readInteger(m)}分`}`)
|
|
65
|
+
// 2026-10-31 → 二零二六年十月三十一日
|
|
66
|
+
.replace(/(?<!\d)(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?!\d)/g, (_w, y, m, d) => `${readDigits(y)}年${readInteger(m)}月${readInteger(d)}日`)
|
|
67
|
+
// 2026年 → 二零二六年
|
|
68
|
+
.replace(/(\d{4})(?=\s*年)/g, (_w, y) => readDigits(y))
|
|
69
|
+
// 20~25度, 3-5个 → 到 (short numbers only: 2026-10-31 is a date)
|
|
70
|
+
.replace(/(?<![\d-])(\d{1,3})\s*[~~—–-]\s*(\d{1,3})(?![\d-])/g, '$1到$2')
|
|
71
|
+
.replace(/\s*(?:℃|°C|°)/g, '度')
|
|
72
|
+
.replace(/(\d+)\.(\d+)/g, (_w, i, f) => `${readInteger(i)}点${readDigits(f)}`)
|
|
73
|
+
.replace(/\d+/g, (n, offset, whole) => {
|
|
74
|
+
const before = whole.slice(0, offset);
|
|
75
|
+
const after = whole.slice(offset + n.length);
|
|
76
|
+
if (n.length >= 7 || (n.length > 1 && n.startsWith('0')) || DIGIT_CONTEXT.test(before))
|
|
77
|
+
return readDigits(n);
|
|
78
|
+
if (n === '2' && MEASURE.test(after))
|
|
79
|
+
return '两';
|
|
80
|
+
return readInteger(n);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function readNumber(n) {
|
|
84
|
+
const [i, f] = n.split('.');
|
|
85
|
+
return f === undefined ? readInteger(i) : `${readInteger(i)}点${readDigits(f)}`;
|
|
86
|
+
}
|
|
87
|
+
/** 2 o'clock is 两点. */
|
|
88
|
+
function hour(h) {
|
|
89
|
+
return Number(h) === 2 ? '两' : readInteger(h);
|
|
90
|
+
}
|
package/lib/core/voices.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* needs the internet; voice/edge.ts);
|
|
6
6
|
* - cosyvoice: Alibaba Cloud Model Studio (百炼) CosyVoice, model
|
|
7
7
|
* cosyvoice-v3-flash (needs a key and paid quota; voice/cosyvoice.ts). Its
|
|
8
|
-
* ids are the API's `voice` parameter, exactly as listed
|
|
8
|
+
* ids are the API's `voice` parameter, exactly as listed;
|
|
9
|
+
* - ming: Ming-3.0-tts on this computer (Apple chips; voice/ming.ts). Built-in
|
|
10
|
+
* voices are `ming:<name>`; voices made on the "她" page (designed from a
|
|
11
|
+
* description, or cloned from a recording) are `ming-own:<id>`.
|
|
9
12
|
*/
|
|
10
13
|
export const VOICE_MODEL = 'cosyvoice-v3-flash';
|
|
11
14
|
export const VOICES = [
|
|
@@ -28,6 +31,13 @@ export const VOICES = [
|
|
|
28
31
|
{ id: 'longyingling_v3', name: '龙应聆', desc: '温和,会共情', service: 'cosyvoice' },
|
|
29
32
|
{ id: 'longanwen_v3', name: '龙安温', desc: '优雅,知性', service: 'cosyvoice' },
|
|
30
33
|
{ id: 'longxiaochun_v3', name: '龙小淳', desc: '知性,积极', service: 'cosyvoice' },
|
|
34
|
+
// Ming's built-in voices that suit her: grown-up women, warm or sweet (the catalog's "girlfriend" voices).
|
|
35
|
+
{ id: 'ming:灵风信', name: '灵风信', desc: '体贴,温暖安心,带情绪', service: 'ming', moods: ['中性', '高兴', '悲伤', '生气', '厌恶'] },
|
|
36
|
+
{ id: 'ming:灵雨汐', name: '灵雨汐', desc: '软糯,有气声,带情绪', service: 'ming', moods: ['中性', '高兴', '悲伤', '惊讶', '恐惧'] },
|
|
37
|
+
{ id: 'ming:灵梦', name: '灵梦', desc: '很甜,爱撒娇', service: 'ming' },
|
|
38
|
+
{ id: 'ming:灵恬', name: '灵恬', desc: '甜蜜,温柔', service: 'ming' },
|
|
39
|
+
{ id: 'ming:灵小汐', name: '灵小汐', desc: '清甜,自然', service: 'ming' },
|
|
40
|
+
{ id: 'ming:灵小芸', name: '灵小芸', desc: '温和,像知心朋友', service: 'ming' },
|
|
31
41
|
];
|
|
32
42
|
export const DEFAULT_VOICE = 'zh-CN-XiaoxiaoNeural';
|
|
33
43
|
export function voiceName(id) {
|
|
@@ -35,9 +45,29 @@ export function voiceName(id) {
|
|
|
35
45
|
}
|
|
36
46
|
/** Which service a voice belongs to; an id not in the list goes by its shape (Edge ids end in "Neural"). */
|
|
37
47
|
export function serviceOf(id) {
|
|
48
|
+
if (id.startsWith('ming:') || id.startsWith(OWN_PREFIX))
|
|
49
|
+
return 'ming';
|
|
38
50
|
return VOICES.find(v => v.id === id)?.service ?? (/Neural$/.test(id) ? 'edge' : 'cosyvoice');
|
|
39
51
|
}
|
|
52
|
+
/** A Ming voice made on the "她" page. */
|
|
53
|
+
export const OWN_PREFIX = 'ming-own:';
|
|
54
|
+
/** Her emotions (core/cues.ts) as Ming moods; the others are said plainly. */
|
|
55
|
+
export const MING_MOODS = { happy: '高兴', sad: '悲伤', angry: '生气', surprised: '惊讶' };
|
|
56
|
+
/** How Ming says a sentence in a voice: a built-in voice (its mood version when it has one) or one of your own, plus mood and speed. */
|
|
57
|
+
export function mingSpeech(id, emotion, speed) {
|
|
58
|
+
const mood = emotion === undefined ? undefined : MING_MOODS[emotion];
|
|
59
|
+
const pace = speed < 0.9 ? '慢速' : speed > 1.1 ? '快速' : undefined;
|
|
60
|
+
const extra = { ...(pace === undefined ? {} : { speed: pace }) };
|
|
61
|
+
if (id.startsWith(OWN_PREFIX))
|
|
62
|
+
return { own: id.slice(OWN_PREFIX.length), ...(mood === undefined ? {} : { emotion: mood }), ...extra };
|
|
63
|
+
const name = id.slice('ming:'.length);
|
|
64
|
+
const moods = VOICES.find(v => v.id === id)?.moods;
|
|
65
|
+
if (moods !== undefined)
|
|
66
|
+
return { voice: `${name}_${mood !== undefined && moods.includes(mood) ? mood : '中性'}`, ...extra };
|
|
67
|
+
return { voice: name, ...(mood === undefined ? {} : { emotion: mood }), ...extra };
|
|
68
|
+
}
|
|
40
69
|
export const SERVICE_NAMES = {
|
|
41
70
|
edge: '微软 Edge(免费,要联网)',
|
|
42
71
|
cosyvoice: '阿里云百炼 CosyVoice(要 key,按字数付费)',
|
|
72
|
+
ming: '本机 Ming(离线、免费、能表达情绪;Apple 芯片,下载约 2 GB)',
|
|
43
73
|
};
|
package/lib/dsh/api.js
CHANGED
|
@@ -69,6 +69,59 @@ export function apiHandlers(rt) {
|
|
|
69
69
|
}),
|
|
70
70
|
}),
|
|
71
71
|
'/voice/preview': async (body) => rt.previewVoice(str(body.voice, '音色'), num(body.speed ?? 1, '语速'), str(body.text ?? '你好呀,我在你桌面上等你好久啦。', '试听的话')),
|
|
72
|
+
'/voice/ming/install': async () => {
|
|
73
|
+
rt.mingInstall();
|
|
74
|
+
return withState();
|
|
75
|
+
},
|
|
76
|
+
'/voice/ming/uninstall': async () => {
|
|
77
|
+
rt.mingUninstall();
|
|
78
|
+
return withState();
|
|
79
|
+
},
|
|
80
|
+
'/voice/ming/restart': async () => {
|
|
81
|
+
await rt.mingRestart();
|
|
82
|
+
return withState();
|
|
83
|
+
},
|
|
84
|
+
'/voice/ming/settings': async (body) => {
|
|
85
|
+
const patch = {};
|
|
86
|
+
if (body.quality === 'quality' || body.quality === 'speed')
|
|
87
|
+
patch.quality = body.quality;
|
|
88
|
+
if (typeof body.moods === 'boolean')
|
|
89
|
+
patch.moods = body.moods;
|
|
90
|
+
if (body.idleMinutes !== undefined) {
|
|
91
|
+
// -1: back to the default for this Mac's memory.
|
|
92
|
+
const minutes = num(body.idleMinutes, '停止前的分钟数');
|
|
93
|
+
if (minutes !== -1 && (minutes < 0 || minutes > 24 * 60))
|
|
94
|
+
throw new ApiError('停止前的分钟数不对');
|
|
95
|
+
Object.assign(patch, { idleMinutes: minutes === -1 ? undefined : minutes });
|
|
96
|
+
}
|
|
97
|
+
rt.mingSettings(patch);
|
|
98
|
+
return withState();
|
|
99
|
+
},
|
|
100
|
+
'/voice/ming/design': async (body) => {
|
|
101
|
+
const description = str(body.description, '声音描述').trim();
|
|
102
|
+
if (description.length < 4 || description.length > 300)
|
|
103
|
+
throw new ApiError('声音描述写 4 到 300 个字');
|
|
104
|
+
return { drafts: await rt.mingDesign(description) };
|
|
105
|
+
},
|
|
106
|
+
'/voice/ming/clone': async (body) => {
|
|
107
|
+
const audio = str(body.audio, '录音');
|
|
108
|
+
// About 20 MB of recording: far more than the 15 seconds that are used.
|
|
109
|
+
if (audio.length > 28_000_000)
|
|
110
|
+
throw new ApiError('录音文件太大了,剪一段 15 秒以内的');
|
|
111
|
+
return { draft: await rt.mingClone(audio, str(body.name ?? 'recording.wav', '文件名')) };
|
|
112
|
+
},
|
|
113
|
+
'/voice/ming/keep': async (body) => {
|
|
114
|
+
const change = {};
|
|
115
|
+
if (body.text !== undefined)
|
|
116
|
+
change.text = str(body.text, '录音里说的话');
|
|
117
|
+
if (body.consent !== undefined)
|
|
118
|
+
change.consent = body.consent === true;
|
|
119
|
+
return withState({ voice: rt.mingKeep(str(body.draft, '试听'), str(body.name, '名字'), change) });
|
|
120
|
+
},
|
|
121
|
+
'/voice/ming/remove': async (body) => {
|
|
122
|
+
rt.mingRemove(str(body.id, '声音'));
|
|
123
|
+
return withState();
|
|
124
|
+
},
|
|
72
125
|
'/body/start': async () => withState({ message: rt.startBody() }),
|
|
73
126
|
'/body/mode': async (body) => {
|
|
74
127
|
const mode = str(body.mode, '模式');
|
package/lib/dsh/plugin.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { spawn } from 'node:child_process';
|
|
33
33
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
34
34
|
import { join } from 'node:path';
|
|
35
|
-
import { buildSystemPrompt, extractCues, herPaths, invalidCharacter, judgeBargeIn, loadCharacter, memoryContext, performanceSection, readingMs, saveCharacter, serviceOf, speakable } from "../core/index.js";
|
|
35
|
+
import { buildSystemPrompt, DEFAULT_VOICE, extractCues, extractSounds, herPaths, invalidCharacter, judgeBargeIn, loadCharacter, memoryContext, OWN_PREFIX, performanceSection, readingMs, saveCharacter, serviceOf, SOUND_SECTION, speakable, withSounds } from "../core/index.js";
|
|
36
36
|
import { digestPrompt, notePrompt, parseDigest, transcriptOf } from "../memory/digest.js";
|
|
37
37
|
import { dayOf, FileMemory } from "../memory/store.js";
|
|
38
38
|
import { PROTOCOL_VERSION } from "../protocol/index.js";
|
|
@@ -46,7 +46,30 @@ import { exampleFile, examples, exportProject, findProject, importProject, listP
|
|
|
46
46
|
import { HerSession } from "./her-session.js";
|
|
47
47
|
import { heardText, SpeechQueue } from "../voice/speech-queue.js";
|
|
48
48
|
import { Voice } from "../voice/voice.js";
|
|
49
|
+
import { defaultIdleMinutes, MingVoice } from "../voice/ming.js";
|
|
50
|
+
import { MING_STEPS, OwnVoices, prepareRecording } from "../voice/ming-voices.js";
|
|
49
51
|
const SERVICES = ['agents', 'agentDefaultModel', 'workspaceRegistry', 'sessionTitle', 'sessionQuery', 'llm'];
|
|
52
|
+
/** The error a model stream chunk carries, if any (an `error` chunk, or a finish whose reason is an error). */
|
|
53
|
+
function chunkError(chunk) {
|
|
54
|
+
const reason = chunk.reason;
|
|
55
|
+
if (chunk.type === 'error')
|
|
56
|
+
return String(chunk.error?.message ?? chunk.message ?? '模型出错了');
|
|
57
|
+
if (reason?.kind === 'error')
|
|
58
|
+
return reason.error?.message ?? '模型出错了';
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/** What to tell you when her model failed: the likely fix, in plain words. */
|
|
62
|
+
export function explainModelError(message, model) {
|
|
63
|
+
if (/no API key|api[_ ]?key|401|unauthori[sz]ed/i.test(message))
|
|
64
|
+
return `她用的模型(${model})没有 API key,没法回答。到「她 → 设置 → 模型」换一个能用的模型,或者在 dsh 的设置里给它填上 key。`;
|
|
65
|
+
if (/quota|insufficient|balance|余额|429/i.test(message))
|
|
66
|
+
return `她用的模型(${model})额度不够或请求太多,暂时没法回答。可以等一会儿,或在「她 → 设置 → 模型」换一个。`;
|
|
67
|
+
if (/timeout|timed out|ECONN|network|fetch failed/i.test(message))
|
|
68
|
+
return `连不上她用的模型(${model}),检查一下网络,或者换一个模型。`;
|
|
69
|
+
return `她用的模型(${model})出错了:${message.slice(0, 160)}`;
|
|
70
|
+
}
|
|
71
|
+
/** After a failed digest, wait this long before trying again (the model is probably not usable yet). */
|
|
72
|
+
const DIGEST_RETRY_MS = 60 * 60_000;
|
|
50
73
|
/** The digest runs this long after you last talked, or from DIGEST_HOUR:DIGEST_MINUTE. */
|
|
51
74
|
const DIGEST_IDLE_MS = 30 * 60_000;
|
|
52
75
|
const DIGEST_HOUR = 23;
|
|
@@ -83,6 +106,10 @@ export function apply(ctx, config) {
|
|
|
83
106
|
/** Resolves on the first sentence of the next reply. */
|
|
84
107
|
let onFirstSentence;
|
|
85
108
|
let voice;
|
|
109
|
+
/** The local Ming voice (made with the voice) and her own Ming voices. */
|
|
110
|
+
let ming;
|
|
111
|
+
const ownVoices = new OwnVoices(join(paths.root, 'voices', 'ming'));
|
|
112
|
+
const usesMing = () => serviceOf(character.voice.id) === 'ming';
|
|
86
113
|
let speech;
|
|
87
114
|
let nextStream = 1;
|
|
88
115
|
/** What you heard of her last cut-off reply; told to her before your next turn. */
|
|
@@ -101,9 +128,22 @@ export function apply(ctx, config) {
|
|
|
101
128
|
/** When you last talked to her, and the day of it (for the digest). */
|
|
102
129
|
let lastTalkAt = 0;
|
|
103
130
|
let digesting;
|
|
131
|
+
/** A digest that failed is not tried again before this (it ran every few minutes against a model with no key). */
|
|
132
|
+
let digestRetryAt = 0;
|
|
133
|
+
/** The last reply that came out empty because her model failed; cleared by the next sentence she says. */
|
|
134
|
+
let problem;
|
|
104
135
|
/** Write her diary page for `day` and keep what is worth keeping; a short report. */
|
|
105
136
|
const digest = (day) => {
|
|
106
|
-
digesting ??= runDigest(day)
|
|
137
|
+
digesting ??= runDigest(day)
|
|
138
|
+
.then(report => {
|
|
139
|
+
if (report.includes('失败'))
|
|
140
|
+
digestRetryAt = Date.now() + DIGEST_RETRY_MS;
|
|
141
|
+
return report;
|
|
142
|
+
}, (error) => {
|
|
143
|
+
digestRetryAt = Date.now() + DIGEST_RETRY_MS;
|
|
144
|
+
throw error;
|
|
145
|
+
})
|
|
146
|
+
.finally(() => { digesting = undefined; });
|
|
107
147
|
return digesting;
|
|
108
148
|
};
|
|
109
149
|
/** One model call outside her session (digest, persona drafts): the whole answer as text. */
|
|
@@ -119,6 +159,9 @@ export function apply(ctx, config) {
|
|
|
119
159
|
})) {
|
|
120
160
|
if (chunk.type === 'text-delta' && typeof chunk.text === 'string')
|
|
121
161
|
answer += chunk.text;
|
|
162
|
+
const error = chunkError(chunk);
|
|
163
|
+
if (error !== undefined)
|
|
164
|
+
throw new Error(error);
|
|
122
165
|
}
|
|
123
166
|
return answer.trim();
|
|
124
167
|
};
|
|
@@ -182,6 +225,8 @@ export function apply(ctx, config) {
|
|
|
182
225
|
const digestTick = () => {
|
|
183
226
|
const now = new Date();
|
|
184
227
|
memory.settle(now);
|
|
228
|
+
if (now.getTime() < digestRetryAt)
|
|
229
|
+
return;
|
|
185
230
|
const done = memory.lastDigestDay();
|
|
186
231
|
if (lastTalkAt === 0) {
|
|
187
232
|
// DSH restarted since you last talked: catch up on yesterday, if it was never digested.
|
|
@@ -337,13 +382,20 @@ export function apply(ctx, config) {
|
|
|
337
382
|
};
|
|
338
383
|
const broadcast = (message) => server?.broadcast(message);
|
|
339
384
|
/** A reply with no words: record what the turn did instead (tool calls, reasoning only, errors). */
|
|
385
|
+
/** Log the end of her session after a reply with no words; the model's error, when the turn ended on one. */
|
|
340
386
|
const logQuietTurn = async () => {
|
|
341
387
|
const id = session?.sessionId;
|
|
342
388
|
const query = services?.sessionQuery;
|
|
343
389
|
if (id === undefined || query === undefined)
|
|
344
|
-
return;
|
|
390
|
+
return undefined;
|
|
345
391
|
try {
|
|
346
|
-
|
|
392
|
+
// The reply's end can come before DSH has written the turn's end (and its error): wait for it a little.
|
|
393
|
+
let events = (await query.readSession(id)).events.slice(-12);
|
|
394
|
+
for (let i = 0; i < 10 && !events.some(e => e.type === 'turn/end'); i++) {
|
|
395
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
396
|
+
events = (await query.readSession(id)).events.slice(-12);
|
|
397
|
+
}
|
|
398
|
+
const failed = events.findLast(e => e.type === 'turn/end' && e.data.reason?.kind === 'error');
|
|
347
399
|
log({
|
|
348
400
|
event: 'quiet-turn',
|
|
349
401
|
events: events.map(e => {
|
|
@@ -357,9 +409,11 @@ export function apply(ctx, config) {
|
|
|
357
409
|
return e.type;
|
|
358
410
|
}),
|
|
359
411
|
});
|
|
412
|
+
return failed === undefined ? undefined : failed.data.reason.error?.message ?? '模型出错了';
|
|
360
413
|
}
|
|
361
414
|
catch (error) {
|
|
362
415
|
log({ event: 'quiet-turn', error: error.message });
|
|
416
|
+
return undefined;
|
|
363
417
|
}
|
|
364
418
|
};
|
|
365
419
|
/** Her model: the "她" page's choice, else the plugin config's, else DSH's default. */
|
|
@@ -391,6 +445,8 @@ export function apply(ctx, config) {
|
|
|
391
445
|
cut();
|
|
392
446
|
sentAt = Date.now();
|
|
393
447
|
lastTalkAt = sentAt;
|
|
448
|
+
// A voice that has to start up (Ming after a quiet while) starts while she thinks.
|
|
449
|
+
voice?.warm();
|
|
394
450
|
broadcast({ t: 'state', cid, state: 'think' });
|
|
395
451
|
clearTimeout(stuckTimer);
|
|
396
452
|
stuckTimer = setTimeout(() => {
|
|
@@ -537,7 +593,10 @@ export function apply(ctx, config) {
|
|
|
537
593
|
platform: process.platform,
|
|
538
594
|
character,
|
|
539
595
|
model: target === undefined ? null : { provider: target.provider, model: target.model, ...(target.reasoningEffort === undefined ? {} : { effort: target.reasoningEffort }), chosen: character.model !== undefined },
|
|
540
|
-
voice: {
|
|
596
|
+
voice: {
|
|
597
|
+
status: voice?.status ?? 'off', speaking: voice?.speaking ?? { status: 'off', service: serviceOf(character.voice.id) }, id: character.voice.id, speed: character.voice.speed,
|
|
598
|
+
ming: { ...(ming?.status ?? { state: 'stopped' }), installed: ming?.installed ?? false, settings: ownVoices.settings(), own: ownVoices.list() },
|
|
599
|
+
},
|
|
541
600
|
body: {
|
|
542
601
|
connected: body !== undefined,
|
|
543
602
|
running: body !== undefined || bodyRunning(paths.root),
|
|
@@ -553,6 +612,7 @@ export function apply(ctx, config) {
|
|
|
553
612
|
},
|
|
554
613
|
import: importStatus,
|
|
555
614
|
...(lastHeard === undefined ? {} : { heard: lastHeard }),
|
|
615
|
+
...(problem === undefined ? {} : { problem }),
|
|
556
616
|
memory: { facts, episodes, diary, lastDigestDay: memory.lastDigestDay() ?? null },
|
|
557
617
|
};
|
|
558
618
|
},
|
|
@@ -695,6 +755,49 @@ export function apply(ctx, config) {
|
|
|
695
755
|
throw new Error('没有这个场景');
|
|
696
756
|
broadcast({ t: 'look.set', ...change });
|
|
697
757
|
},
|
|
758
|
+
mingInstall: () => {
|
|
759
|
+
if (ming === undefined)
|
|
760
|
+
throw new Error('声音还没准备好');
|
|
761
|
+
void ming.install().catch(error => ctx.logger.warn(`dsh-her: ${error.message}`));
|
|
762
|
+
},
|
|
763
|
+
mingUninstall: () => {
|
|
764
|
+
if (usesMing())
|
|
765
|
+
api.saveCharacter({ voice: { id: DEFAULT_VOICE, speed: character.voice.speed } });
|
|
766
|
+
ming?.uninstall();
|
|
767
|
+
},
|
|
768
|
+
mingRestart: async () => {
|
|
769
|
+
await ming?.restart();
|
|
770
|
+
},
|
|
771
|
+
mingSettings: patch => {
|
|
772
|
+
const next = ownVoices.saveSettings(patch);
|
|
773
|
+
ming?.setIdleMinutes(next.idleMinutes ?? defaultIdleMinutes());
|
|
774
|
+
},
|
|
775
|
+
mingDesign: async (description) => {
|
|
776
|
+
if (ming === undefined)
|
|
777
|
+
throw new Error('声音还没准备好');
|
|
778
|
+
const text = `你好呀,我是${character.name}。今天过得怎么样?有什么想跟我说的吗?`;
|
|
779
|
+
const drafts = [];
|
|
780
|
+
// Three takes of one description: each comes out a little different; you keep the one you like.
|
|
781
|
+
for (const seed of [11, 22, 33]) {
|
|
782
|
+
const audio = await ming.synthesize({ text, design: description, steps: MING_STEPS.quality, seed });
|
|
783
|
+
const id = ownVoices.addDraft(audio.audio, { kind: 'design', text, description });
|
|
784
|
+
drafts.push({ id, audio: Buffer.from(audio.audio).toString('base64'), durationMs: audio.durationMs });
|
|
785
|
+
}
|
|
786
|
+
return drafts;
|
|
787
|
+
},
|
|
788
|
+
mingClone: async (audio, name) => {
|
|
789
|
+
const recording = await prepareRecording(Buffer.from(audio, 'base64'), name);
|
|
790
|
+
// What it says, to start from (Ming needs the words); you correct it on the page.
|
|
791
|
+
const heard = await voice?.recognize(recording.pcm16k, 16_000).catch(() => undefined);
|
|
792
|
+
const id = ownVoices.addDraft(recording.wav, { kind: 'clone', text: heard?.text ?? '' });
|
|
793
|
+
return { id, audio: Buffer.from(recording.wav).toString('base64'), durationMs: Math.round(recording.seconds * 1000), text: heard?.text ?? '' };
|
|
794
|
+
},
|
|
795
|
+
mingKeep: (draft, name, change) => ownVoices.keep(draft, name, change),
|
|
796
|
+
mingRemove: id => {
|
|
797
|
+
if (character.voice.id === `${OWN_PREFIX}${id}`)
|
|
798
|
+
api.saveCharacter({ voice: { id: 'ming:灵风信', speed: character.voice.speed } });
|
|
799
|
+
ownVoices.remove(id);
|
|
800
|
+
},
|
|
698
801
|
memoryRemove: ids => memory.remove(ids),
|
|
699
802
|
memoryConfirm: ids => memory.confirm(ids),
|
|
700
803
|
digest: () => digest(dayOf(new Date())),
|
|
@@ -706,7 +809,7 @@ export function apply(ctx, config) {
|
|
|
706
809
|
session = new HerSession({
|
|
707
810
|
cid,
|
|
708
811
|
character: () => character,
|
|
709
|
-
performance: () => performanceSection(bodyPack),
|
|
812
|
+
performance: () => [performanceSection(bodyPack), usesMing() ? SOUND_SECTION : ''].filter(Boolean).join('\n'),
|
|
710
813
|
playAction,
|
|
711
814
|
aboutYou,
|
|
712
815
|
memory,
|
|
@@ -731,20 +834,30 @@ export function apply(ctx, config) {
|
|
|
731
834
|
}
|
|
732
835
|
onFirstSentence?.();
|
|
733
836
|
onFirstSentence = undefined;
|
|
837
|
+
problem = undefined;
|
|
734
838
|
log({ event: 'sentence', attemptId, text, sinceSendMs: sentAt === undefined ? null : now - sentAt });
|
|
735
|
-
|
|
839
|
+
// Ming can sigh and laugh: those tags become its sound markers, out of the subtitle.
|
|
840
|
+
const sounds = usesMing() ? extractSounds(text) : undefined;
|
|
841
|
+
const performed = extractCues(sounds?.text ?? text, bodyPack);
|
|
842
|
+
const spoken = sounds !== undefined && sounds.before.length + sounds.after.length > 0 ? withSounds(performed.text, sounds) : undefined;
|
|
736
843
|
if (performed.text !== '' || performed.cues.length > 0)
|
|
737
|
-
speech?.say(attemptId, seq, performed.text, performed.cues);
|
|
844
|
+
speech?.say(attemptId, seq, performed.text, performed.cues, spoken);
|
|
738
845
|
},
|
|
739
846
|
end(attemptId, outcome) {
|
|
740
847
|
log({ event: 'end', attemptId, outcome, sinceSendMs: sentAt === undefined ? null : Date.now() - sentAt });
|
|
741
848
|
if (current?.attemptId === attemptId) {
|
|
742
849
|
current.outcome = outcome;
|
|
743
|
-
if (current.sentences.length === 0
|
|
744
|
-
|
|
850
|
+
if (current.sentences.length === 0) {
|
|
851
|
+
// No words at all: say why, on her and on the page (a model without a key used to fail silently).
|
|
852
|
+
const model = route();
|
|
853
|
+
void logQuietTurn().then(error => {
|
|
854
|
+
if (error === undefined && outcome === 'committed')
|
|
855
|
+
return;
|
|
856
|
+
const text = error === undefined ? '我刚才没说出话来,模型可能出错了' : explainModelError(error, model === undefined ? '没有模型' : `${model.provider} / ${model.model}`);
|
|
857
|
+
problem = { text, at: Date.now() };
|
|
858
|
+
broadcast({ t: 'bubble', cid, text: `(${text})`, ttlMs: 12_000 });
|
|
859
|
+
});
|
|
745
860
|
}
|
|
746
|
-
if (current.sentences.length === 0)
|
|
747
|
-
void logQuietTurn();
|
|
748
861
|
finished.push(current);
|
|
749
862
|
if (finished.length > 20)
|
|
750
863
|
finished.shift();
|
|
@@ -756,10 +869,11 @@ export function apply(ctx, config) {
|
|
|
756
869
|
});
|
|
757
870
|
if (services !== undefined)
|
|
758
871
|
open();
|
|
759
|
-
|
|
872
|
+
ming = new MingVoice(join(paths.root, 'ming'), join(paths.root, 'uv'), ctx.logger, ownVoices.settings().idleMinutes ?? defaultIdleMinutes());
|
|
873
|
+
voice = new Voice(paths.models, join(paths.root, 'edge-tts'), { ...config.voice, voice: character.voice.id, speed: character.voice.speed }, ctx.logger, { voice: ming, own: ownVoices });
|
|
760
874
|
const herVoice = voice;
|
|
761
875
|
void herVoice.start();
|
|
762
|
-
speech = new SpeechQueue(text => herVoice.synthesize(speakable(text)), {
|
|
876
|
+
speech = new SpeechQueue((text, cues) => herVoice.synthesize(speakable(text), cues), {
|
|
763
877
|
sentence(uid, seq, text, audio, cues) {
|
|
764
878
|
const withCues = cues.length === 0 ? {} : { cues };
|
|
765
879
|
if (audio === undefined) {
|
|
@@ -839,6 +953,7 @@ export function apply(ctx, config) {
|
|
|
839
953
|
clearTimeout(stuckTimer);
|
|
840
954
|
herVoice.stop();
|
|
841
955
|
voice = undefined;
|
|
956
|
+
ming = undefined;
|
|
842
957
|
speech = undefined;
|
|
843
958
|
const closing = session;
|
|
844
959
|
session = undefined;
|
|
@@ -12,7 +12,7 @@ export interface Character {
|
|
|
12
12
|
callYou: string;
|
|
13
13
|
/** How she refers to you in her notes and memories. */
|
|
14
14
|
pronoun: Pronoun;
|
|
15
|
-
/** A
|
|
15
|
+
/** A voice id (voices.ts: Edge, CosyVoice or Ming), and how fast she talks. */
|
|
16
16
|
voice: {
|
|
17
17
|
id: string;
|
|
18
18
|
speed: number;
|
|
@@ -5,6 +5,8 @@ export { judgeBargeIn, type EchoVerdict } from './echo.ts';
|
|
|
5
5
|
export { EMOTION_WORDS, extractCues, isHeavy, performanceSection } from './cues.ts';
|
|
6
6
|
export { buildSystemPrompt, memoryContext, SAMPLE_PERSONA, type MemoryContext, type PromptParts } from './prompt.ts';
|
|
7
7
|
export { herPaths, type HerPaths } from './paths.ts';
|
|
8
|
-
export { DEFAULT_VOICE, SERVICE_NAMES, serviceOf, VOICE_MODEL, VOICES, voiceName, type VoiceOption, type VoiceService } from './voices.ts';
|
|
8
|
+
export { DEFAULT_VOICE, MING_MOODS, mingSpeech, OWN_PREFIX, SERVICE_NAMES, serviceOf, VOICE_MODEL, VOICES, voiceName, type VoiceOption, type VoiceService } from './voices.ts';
|
|
9
|
+
export { extractSounds, SOUND_SECTION, withSounds } from './sounds.ts';
|
|
10
|
+
export { spokenNumbers } from './spoken-numbers.ts';
|
|
9
11
|
export { invalidCharacter, loadCharacter, saveCharacter, type Character, type Pronoun } from './character.ts';
|
|
10
12
|
export { decodeWav, encodeWav, wavFromPcm16 } from './wav.ts';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sounds she can make with the Ming voice (docs/Ming本地声音设计.md §5): she
|
|
3
|
+
* writes 〔叹气〕 or 〔笑〕 in a sentence; the tag is taken out of the subtitle
|
|
4
|
+
* and Ming's own marker ([Sigh], [Laughter]) goes at the start or the end of
|
|
5
|
+
* what is spoken. A marker in the middle of a sentence swallows the word next
|
|
6
|
+
* to it, so it is moved to whichever end the tag was nearer.
|
|
7
|
+
*
|
|
8
|
+
* With another voice the tags are simply dropped (extractCues removes every
|
|
9
|
+
* tag it does not know), and her prompt never mentions them.
|
|
10
|
+
*/
|
|
11
|
+
export interface Sounds {
|
|
12
|
+
/** The sentence without the sound tags. */
|
|
13
|
+
text: string;
|
|
14
|
+
before: string[];
|
|
15
|
+
after: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function extractSounds(text: string): Sounds;
|
|
18
|
+
/** What Ming says: the sentence with its sound markers at the ends. */
|
|
19
|
+
export declare function withSounds(spoken: string, sounds: Pick<Sounds, 'before' | 'after'>): string;
|
|
20
|
+
/** The lines of her prompt that teach the sounds (only while she speaks with Ming). */
|
|
21
|
+
export declare const SOUND_SECTION = "\u4F60\u7684\u58F0\u97F3\u80FD\u771F\u7684\u53F9\u6C14\u548C\u7B11\u51FA\u58F0\uFF1A\u5728\u53E5\u5B50\u5F00\u5934\u6216\u7ED3\u5C3E\u5199\u3014\u53F9\u6C14\u3015\u6216\u3014\u7B11\u3015\uFF08\u4E0D\u4F1A\u663E\u793A\uFF09\u3002\u53EA\u5728\u771F\u7684\u60F3\u53F9\u6C14\u3001\u7B11\u51FA\u6765\u65F6\u7528\uFF0C\u4E00\u6B21\u56DE\u590D\u6700\u591A\u4E00\u6B21\u3002";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Digits and a few symbols written out the way they are said, for the Ming
|
|
3
|
+
* voice, which reads "2个" as "二个" (its MLX port has no text
|
|
4
|
+
* normalization). Only what turns up in her chat: counts, times, dates,
|
|
5
|
+
* years, percentages, decimals, phone numbers and codes, ranges, degrees.
|
|
6
|
+
*/
|
|
7
|
+
/** "2026" → 二零二六. */
|
|
8
|
+
export declare function readDigits(digits: string): string;
|
|
9
|
+
/** "1205" → 一千二百零五; "12" → 十二; "20000" → 两万. Longer than 12 digits: one by one. */
|
|
10
|
+
export declare function readInteger(number: string): string;
|
|
11
|
+
export declare function spokenNumbers(text: string): string;
|
|
@@ -5,19 +5,35 @@
|
|
|
5
5
|
* needs the internet; voice/edge.ts);
|
|
6
6
|
* - cosyvoice: Alibaba Cloud Model Studio (百炼) CosyVoice, model
|
|
7
7
|
* cosyvoice-v3-flash (needs a key and paid quota; voice/cosyvoice.ts). Its
|
|
8
|
-
* ids are the API's `voice` parameter, exactly as listed
|
|
8
|
+
* ids are the API's `voice` parameter, exactly as listed;
|
|
9
|
+
* - ming: Ming-3.0-tts on this computer (Apple chips; voice/ming.ts). Built-in
|
|
10
|
+
* voices are `ming:<name>`; voices made on the "她" page (designed from a
|
|
11
|
+
* description, or cloned from a recording) are `ming-own:<id>`.
|
|
9
12
|
*/
|
|
10
13
|
export declare const VOICE_MODEL = "cosyvoice-v3-flash";
|
|
11
|
-
export type VoiceService = 'edge' | 'cosyvoice';
|
|
14
|
+
export type VoiceService = 'edge' | 'cosyvoice' | 'ming';
|
|
12
15
|
export interface VoiceOption {
|
|
13
16
|
id: string;
|
|
14
17
|
name: string;
|
|
15
18
|
desc: string;
|
|
16
19
|
service: VoiceService;
|
|
20
|
+
/** Ming voices recorded in several moods: the moods it has (`灵风信_高兴` …). */
|
|
21
|
+
moods?: readonly string[];
|
|
17
22
|
}
|
|
18
23
|
export declare const VOICES: readonly VoiceOption[];
|
|
19
24
|
export declare const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural";
|
|
20
25
|
export declare function voiceName(id: string): string;
|
|
21
26
|
/** Which service a voice belongs to; an id not in the list goes by its shape (Edge ids end in "Neural"). */
|
|
22
27
|
export declare function serviceOf(id: string): VoiceService;
|
|
28
|
+
/** A Ming voice made on the "她" page. */
|
|
29
|
+
export declare const OWN_PREFIX = "ming-own:";
|
|
30
|
+
/** Her emotions (core/cues.ts) as Ming moods; the others are said plainly. */
|
|
31
|
+
export declare const MING_MOODS: Record<string, string>;
|
|
32
|
+
/** How Ming says a sentence in a voice: a built-in voice (its mood version when it has one) or one of your own, plus mood and speed. */
|
|
33
|
+
export declare function mingSpeech(id: string, emotion: string | undefined, speed: number): {
|
|
34
|
+
voice?: string;
|
|
35
|
+
own?: string;
|
|
36
|
+
emotion?: string;
|
|
37
|
+
speed?: string;
|
|
38
|
+
};
|
|
23
39
|
export declare const SERVICE_NAMES: Record<VoiceService, string>;
|