dsh-her 0.1.0-beta.1 → 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.
@@ -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
+ }
@@ -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
  };
@@ -4,7 +4,7 @@
4
4
  * (dsh/workshop.ts), the "她" page edits the project.
5
5
  *
6
6
  * Prompts come in three layers:
7
- * - rules: fixed, by kind (desk pet or scene) and generator;
7
+ * - rules: fixed, by kind (desk pet or scene);
8
8
  * - who she is: one sentence from the character card, the same in every
9
9
  * image and every clip, so she stays the same person;
10
10
  * - the action: one per clip, preset or the user's own.
@@ -62,6 +62,33 @@ export function screenColor(card) {
62
62
  return /绿/.test(`${card.outfit}${card.extra}${card.sentence}`) ? { name: '纯灰色', hex: '#808080' } : { name: '纯绿色', hex: '#00B140' };
63
63
  }
64
64
  export const POSE_WORDS = { stand: '站着', sit: '坐着', lie: '躺着' };
65
+ const WEB_HOW = '一段段生成:上传首帧,粘贴提示词(有"负面提示词"框就把负面提示词也粘进去,有"尾帧"就也放首帧那张图),下载后按文件名存进对应的文件夹。「生成清单.md」里每段都列好了。';
66
+ export const GENERATORS = {
67
+ minimax: {
68
+ name: 'MiniMax Code', label: 'MiniMax Code(给它任务文件,自动生成下载)',
69
+ settings: 'MiniMax-Hailuo-2.3 图生视频,1080P,6 秒。',
70
+ how: '在 MiniMax Code 里说:"按 <项目文件夹>/MiniMax生成任务.md 生成",它会一段段生成、下载、存好。',
71
+ },
72
+ comfyui: {
73
+ name: 'ComfyUI', label: 'ComfyUI(本地,Wan 2.2 等图生视频工作流)',
74
+ settings: '图生视频工作流(比如 Wan 2.2 I2V),尺寸和首帧同比例、短边 720 以上,约 5 秒(16 fps 时 81 帧),负面提示词放进负面节点,每段用同一套参数。',
75
+ how: '用你的图生视频工作流一段段跑,或者用脚本读「ComfyUI生成清单.json」(每段的首帧、提示词、负面提示词、保存路径)批量跑。输出存成清单里的文件名。',
76
+ },
77
+ jimeng: { name: '即梦', label: '即梦(网页 / App)', settings: '图生视频,1080P,5 秒。', how: `在即梦里${WEB_HOW}` },
78
+ kling: { name: '可灵', label: '可灵(网页 / App)', settings: '图生视频,1080P,5 秒。', how: `在可灵里${WEB_HOW}` },
79
+ hailuo: { name: '海螺 AI', label: '海螺 AI(MiniMax 的网页版)', settings: '图生视频,1080P,6 秒。', how: `在海螺 AI 里${WEB_HOW}` },
80
+ wanx: { name: '通义万相', label: '通义万相(网页 / 阿里云百炼)', settings: '图生视频,1080P,5 秒。', how: `在通义万相里${WEB_HOW}` },
81
+ vidu: { name: 'Vidu', label: 'Vidu(网页)', settings: '图生视频,1080P,4–5 秒。', how: `在 Vidu 里${WEB_HOW}` },
82
+ };
83
+ export const MANUAL_LABEL = '手工填写(别的工具,自己写名字和设置)';
84
+ /** The project's tool, a custom one filled in from `custom`. */
85
+ export function generatorInfo(project) {
86
+ if (project.generator !== 'manual' && project.generator in GENERATORS)
87
+ return GENERATORS[project.generator];
88
+ const name = project.custom?.name.trim() || '你用的工具';
89
+ const settings = project.custom?.settings.trim() || '图生视频,和首帧同比例,1080P,5–6 秒,每段用同一套设置。';
90
+ return { name, label: MANUAL_LABEL, settings, how: `在${name}里${WEB_HOW}` };
91
+ }
65
92
  /** Preset actions; `{pose}` reads 站着 / 坐着 / 躺着. */
66
93
  const PRESETS = [
67
94
  { role: 'idle', n: 1, required: true, action: '安静地{pose},自然呼吸,偶尔眨眼,头极轻微地晃动,嘴巴闭合,浅浅地笑' },
@@ -124,7 +151,8 @@ export function rules(project, kind, scene) {
124
151
  export const NEGATIVE = '换脸, 身份漂移, 发型变化, 服装变化, 年龄变化, 多余人物, 多余手指, 手部畸形, 肢体扭曲, 脚步移动, 走出画面, 瞬移, 镜头移动, 变焦, 切镜, 背景变化, 光线闪烁, 阴影出现, 文字, 字幕, 水印, logo, 夸张表情, 卡通化, 塑料皮肤, 模糊, 低清';
125
152
  /** The full prompt of one clip: rules, who she is, the action. */
126
153
  export function clipPrompt(project, kind, clip, scene) {
127
- return `${rules(project, kind, scene)}她是:${describe(project.card)}。${clip.action}。`;
154
+ const prefix = project.generator === 'manual' ? project.custom?.prefix.trim() ?? '' : '';
155
+ return `${prefix === '' ? '' : `${prefix.replace(/[,。,.\s]+$/, '')}。`}${rules(project, kind, scene)}她是:${describe(project.card)}。${clip.action}。`;
128
156
  }
129
157
  /** The reference picture: her on a plain screen, full length. It is also the desk pet's anchor. */
130
158
  export function referencePrompt(project) {
@@ -173,7 +201,8 @@ export function promptsFile(project, part) {
173
201
  return [
174
202
  `${project.name} · ${part === 'desk' ? '桌宠' : part.name} · 视频提示词`,
175
203
  '',
176
- `首帧(和尾帧,工具支持的话):${first}。比例和首帧一致,5–6 秒,选 1080P。`,
204
+ `用${generatorInfo(project).name}:${generatorInfo(project).settings}`,
205
+ `首帧(和尾帧,工具支持的话):${first}。比例和首帧一致。`,
177
206
  '生成好的视频按下面的文件名命名(.mp4),放进这个文件夹。',
178
207
  '',
179
208
  `负面提示词:${NEGATIVE}`,
@@ -181,6 +210,35 @@ export function promptsFile(project, part) {
181
210
  ...clips.filter(c => c.off !== true).flatMap(c => [`${c.file}.mp4${c.required === true ? '(必须)' : ''}`, clipPrompt(project, kind, c, scene), '']),
182
211
  ].join('\n');
183
212
  }
213
+ /** 生成清单.md: every clip with its first frame, where it goes and its prompt, for any tool. */
214
+ export function clipList(project, root) {
215
+ const tool = generatorInfo(project);
216
+ const lines = [
217
+ `# ${project.name} · 视频片段清单(${tool.name})`,
218
+ '',
219
+ `统一设置:${tool.settings}`,
220
+ '提示词原样使用;首尾接不上没关系,导入时会自动处理成循环。已经有的 mp4 不用重做。',
221
+ '',
222
+ `负面提示词(工具有这个框就用):${NEGATIVE}`,
223
+ '',
224
+ ];
225
+ for (const item of plannedClips(project)) {
226
+ lines.push(`## ${item.part} / ${item.clip.file}${item.clip.required === true ? '(必须)' : ''}`, `- 首帧:\`${root}/${item.first}\``, `- 保存到:\`${root}/${item.folder}/${item.clip.file}.mp4\``, `- 提示词:${item.prompt}`, '');
227
+ }
228
+ return lines.join('\n');
229
+ }
230
+ /** ComfyUI生成清单.json: the same list for a script that drives a ComfyUI workflow. */
231
+ export function comfyuiList(project, root) {
232
+ return `${JSON.stringify({
233
+ format: 'dsh-her-clips@1',
234
+ settings: GENERATORS.comfyui.settings,
235
+ negative: NEGATIVE,
236
+ clips: plannedClips(project).map(item => ({
237
+ part: item.part, name: item.clip.file, required: item.clip.required === true,
238
+ image: `${root}/${item.first}`, output: `${root}/${item.folder}/${item.clip.file}.mp4`, prompt: item.prompt,
239
+ })),
240
+ }, null, 2)}\n`;
241
+ }
184
242
  /** The task file for MiniMax Code: one uniform setting, at most 5 at a time, download as each finishes. */
185
243
  export function minimaxTask(project, root) {
186
244
  const lines = [
@@ -238,6 +296,10 @@ export function validateProject(value) {
238
296
  throw new Error('项目内容不完整');
239
297
  if (!p.desk.on && p.scenes.length === 0)
240
298
  throw new Error('桌宠和场景至少要有一个');
299
+ if (p.generator !== 'manual' && !(p.generator in GENERATORS))
300
+ throw new Error('不认识这个生成工具');
301
+ if (p.custom !== undefined && (typeof p.custom !== 'object' || p.custom === null || [p.custom.name, p.custom.settings, p.custom.prefix].some(v => typeof v !== 'string')))
302
+ throw new Error('手工填写的工具信息不对');
241
303
  const names = new Set();
242
304
  for (const scene of p.scenes) {
243
305
  if (typeof scene.id !== 'string' || typeof scene.name !== 'string' || scene.name.trim() === '' || !Array.isArray(scene.clips))
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).finally(() => { digesting = undefined; });
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
- const events = (await query.readSession(id)).events.slice(-12);
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: { status: voice?.status ?? 'off', speaking: voice?.speaking ?? { status: 'off', service: serviceOf(character.voice.id) }, id: character.voice.id, speed: character.voice.speed },
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
- const performed = extractCues(text, bodyPack);
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 && outcome !== 'committed') {
744
- broadcast({ t: 'bubble', cid, text: '(我刚才没说出话来,模型可能出错了)', ttlMs: 6000 });
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
- voice = new Voice(paths.models, join(paths.root, 'edge-tts'), { ...config.voice, voice: character.voice.id, speed: character.voice.speed }, ctx.logger);
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;