dsh-her 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/cordis.patch.yml +5 -0
  4. package/examples/workshop/README.md +20 -0
  5. package/lib/client.js +1602 -0
  6. package/lib/core/character.js +50 -0
  7. package/lib/core/cues.js +62 -0
  8. package/lib/core/echo.js +27 -0
  9. package/lib/core/index.js +10 -0
  10. package/lib/core/paths.js +14 -0
  11. package/lib/core/prompt.js +63 -0
  12. package/lib/core/sentences.js +115 -0
  13. package/lib/core/speakable.js +26 -0
  14. package/lib/core/voices.js +43 -0
  15. package/lib/core/wav.js +71 -0
  16. package/lib/core/workshop.js +330 -0
  17. package/lib/dsh/api.js +156 -0
  18. package/lib/dsh/body-install.js +137 -0
  19. package/lib/dsh/body-launcher.js +48 -0
  20. package/lib/dsh/config.js +27 -0
  21. package/lib/dsh/her-session.js +282 -0
  22. package/lib/dsh/import-status.js +6 -0
  23. package/lib/dsh/plugin.js +1010 -0
  24. package/lib/dsh/workshop.js +222 -0
  25. package/lib/index.js +11 -0
  26. package/lib/memory/digest.js +119 -0
  27. package/lib/memory/search.js +52 -0
  28. package/lib/memory/store.js +267 -0
  29. package/lib/protocol/index.js +39 -0
  30. package/lib/server/brain-server.js +219 -0
  31. package/lib/types/core/character.d.ts +32 -0
  32. package/lib/types/core/cues.d.ts +20 -0
  33. package/lib/types/core/echo.d.ts +10 -0
  34. package/lib/types/core/index.d.ts +10 -0
  35. package/lib/types/core/paths.d.ts +11 -0
  36. package/lib/types/core/prompt.d.ts +39 -0
  37. package/lib/types/core/sentences.d.ts +40 -0
  38. package/lib/types/core/speakable.d.ts +6 -0
  39. package/lib/types/core/voices.d.ts +23 -0
  40. package/lib/types/core/wav.d.ts +9 -0
  41. package/lib/types/core/workshop.d.ts +156 -0
  42. package/lib/types/dsh/api.d.ts +163 -0
  43. package/lib/types/dsh/body-install.d.ts +58 -0
  44. package/lib/types/dsh/body-launcher.d.ts +28 -0
  45. package/lib/types/dsh/config.d.ts +34 -0
  46. package/lib/types/dsh/her-session.d.ts +215 -0
  47. package/lib/types/dsh/import-status.d.ts +17 -0
  48. package/lib/types/dsh/plugin.d.ts +34 -0
  49. package/lib/types/dsh/workshop.d.ts +94 -0
  50. package/lib/types/index.d.ts +11 -0
  51. package/lib/types/memory/digest.d.ts +33 -0
  52. package/lib/types/memory/search.d.ts +17 -0
  53. package/lib/types/memory/store.d.ts +132 -0
  54. package/lib/types/protocol/index.d.ts +434 -0
  55. package/lib/types/server/brain-server.d.ts +71 -0
  56. package/lib/types/voice/cloud-key.d.ts +10 -0
  57. package/lib/types/voice/cosyvoice.d.ts +47 -0
  58. package/lib/types/voice/edge.d.ts +52 -0
  59. package/lib/types/voice/engine.d.ts +30 -0
  60. package/lib/types/voice/models.d.ts +21 -0
  61. package/lib/types/voice/speech-queue.d.ts +55 -0
  62. package/lib/types/voice/voice.d.ts +75 -0
  63. package/lib/types/voice/worker.d.ts +37 -0
  64. package/lib/voice/cloud-key.js +23 -0
  65. package/lib/voice/cosyvoice.js +194 -0
  66. package/lib/voice/edge.js +199 -0
  67. package/lib/voice/engine.js +101 -0
  68. package/lib/voice/models.js +69 -0
  69. package/lib/voice/speech-queue.js +118 -0
  70. package/lib/voice/voice.js +171 -0
  71. package/lib/voice/worker.js +47 -0
  72. package/package.json +61 -4
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Who she is, as the wizard and the "她" page set it: `<home>/characters/<cid>/character.json`.
3
+ * The plugin config only gives the first defaults; once saved here, this file
4
+ * wins, and changes apply from her next model request (her prompt is rebuilt
5
+ * every time) without restarting DSH.
6
+ */
7
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ export function loadCharacter(dir, defaults) {
10
+ try {
11
+ const saved = JSON.parse(readFileSync(join(dir, 'character.json'), 'utf8'));
12
+ return {
13
+ ...defaults,
14
+ ...saved,
15
+ pronoun: saved.pronoun === '他' || saved.pronoun === '她' || saved.pronoun === 'TA' ? saved.pronoun : defaults.pronoun,
16
+ // Settings saved before the cloud voice name a local speaker number instead: keep the speed only.
17
+ voice: {
18
+ id: typeof saved.voice?.id === 'string' ? saved.voice.id : defaults.voice.id,
19
+ speed: typeof saved.voice?.speed === 'number' ? saved.voice.speed : defaults.voice.speed,
20
+ },
21
+ };
22
+ }
23
+ catch {
24
+ return defaults;
25
+ }
26
+ }
27
+ export function saveCharacter(dir, character) {
28
+ mkdirSync(dir, { recursive: true });
29
+ const file = join(dir, 'character.json');
30
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(character, null, 2)}\n`);
31
+ renameSync(`${file}.tmp`, file);
32
+ }
33
+ /** Check a patch from the page; returns the error message, or undefined when it is fine. */
34
+ export function invalidCharacter(c) {
35
+ if (c.name.trim() === '' || [...c.name].length > 20)
36
+ return '名字要 1 到 20 个字';
37
+ if (c.persona.trim() === '')
38
+ return '人设不能是空的';
39
+ if ([...c.persona].length > 3000)
40
+ return '人设太长了(最多 3000 字)';
41
+ if ([...c.callYou].length > 20)
42
+ return '称呼最多 20 个字';
43
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(c.voice.id))
44
+ return '音色不对';
45
+ if (!(c.voice.speed >= 0.5 && c.voice.speed <= 2))
46
+ return '语速要在 0.5 到 2 之间';
47
+ if (c.model !== undefined && (c.model.provider.trim() === '' || c.model.model.trim() === ''))
48
+ return '模型不对';
49
+ return undefined;
50
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Inline performance tags (docs/大脑与dsh会话.md §6): she writes 〔开心〕 or
3
+ * 〔动作:wave〕 in her reply; they are taken out of the text (never shown,
4
+ * never spoken) and sent as cues with the sentence, so the body acts them out
5
+ * when that sentence starts. Only what the connected pack can do is kept.
6
+ *
7
+ * The prompt section that teaches her the tags is generated from the same
8
+ * pack summary, so she only ever hears of moves her body has.
9
+ */
10
+ /** Chinese names for the emotions a pack may have. */
11
+ export const EMOTION_WORDS = {
12
+ happy: '开心', shy: '害羞', pout: '嘟嘴', sad: '难过', angry: '生气', surprised: '惊讶', sleepy: '困',
13
+ };
14
+ const TAG = /〔([^〕]{1,24})〕/g;
15
+ /** Light actions are acted inline; heavy ones (outfits, stored lines) go through the play_action tool. */
16
+ export function isHeavy(action) {
17
+ return action.id.startsWith('outfit_') || action.id.startsWith('line_') || action.id.startsWith('scene_') || (action.durationMs ?? 0) > 6000;
18
+ }
19
+ export function extractCues(text, pack) {
20
+ const cues = [];
21
+ const clean = text.replace(TAG, (_whole, inner) => {
22
+ if (pack === undefined)
23
+ return '';
24
+ const body = inner.trim();
25
+ const action = /^动作\s*[::]\s*(.+)$/.exec(body)?.[1]?.trim();
26
+ if (action !== undefined) {
27
+ const found = pack.actions.find(a => !isHeavy(a) && (a.id === action || a.desc === action));
28
+ if (found !== undefined)
29
+ cues.push({ kind: 'act', name: found.id });
30
+ return '';
31
+ }
32
+ const emotion = pack.emotions.find(e => e === body || EMOTION_WORDS[e] === body);
33
+ if (emotion !== undefined)
34
+ cues.push({ kind: 'emotion', name: emotion });
35
+ return '';
36
+ });
37
+ return { text: clean.replace(/\s{2,}/g, ' ').trim(), cues };
38
+ }
39
+ /** The "表演" section of her system prompt, for what this pack can do; empty without a pack. */
40
+ export function performanceSection(pack) {
41
+ if (pack === undefined)
42
+ return '';
43
+ const lines = [];
44
+ const emotions = pack.emotions.map(e => EMOTION_WORDS[e] ?? e);
45
+ const light = pack.actions.filter(a => !isHeavy(a));
46
+ const heavy = pack.actions.filter(isHeavy);
47
+ if (emotions.length > 0 || light.length > 0) {
48
+ lines.push('你在对方桌面上有一个身体。可以在句子里加标记,让身体做出来(标记不会被念出来,也不会显示):');
49
+ if (emotions.length > 0)
50
+ lines.push(`- 情绪:${emotions.map(e => `〔${e}〕`).join(' ')}`);
51
+ for (const action of light)
52
+ lines.push(`- 〔动作:${action.id}〕:${action.desc}`);
53
+ lines.push('标记放在对应那句话的开头,偶尔用,不要每句都加。');
54
+ }
55
+ if (heavy.length > 0) {
56
+ lines.push('需要较长时间的表演(离开画面、换衣服、换个地方、说准备好的话),调用 play_action 工具,等它做完再接着说。调用前先简短说一句,比如"好,等我一下":');
57
+ for (const action of heavy)
58
+ lines.push(`- ${action.id}:${action.desc}`);
59
+ }
60
+ lines.push('只有上面列出的表演能做,标记必须一字不差;不要自己编标记(比如〔歪头〕),也不要用【】或括号写动作;没列出的,用说的。');
61
+ return lines.join('\n');
62
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Was that you, or her own voice coming back through the speakers?
3
+ *
4
+ * Right after a barge-in the brain checks what was recognized against what
5
+ * she was saying. Mostly her own words (character-bigram overlap), or only a
6
+ * filler sound (嗯, 啊…), means it was not you: it is dropped instead of
7
+ * becoming your turn, and the body counts it toward turning barge-in off.
8
+ */
9
+ const FILLERS = /^[嗯啊哦噢呃额唔哈呀诶欸嗷恩]+$/u;
10
+ const ECHO_OVERLAP = 0.6;
11
+ function bigrams(text) {
12
+ const chars = [...text.replace(/[^\p{L}\p{N}]/gu, '')];
13
+ if (chars.length < 2)
14
+ return chars;
15
+ return chars.slice(1).map((ch, i) => chars[i] + ch);
16
+ }
17
+ export function judgeBargeIn(heard, herWords) {
18
+ const plain = heard.replace(/[^\p{L}\p{N}]/gu, '');
19
+ if (plain === '' || FILLERS.test(plain))
20
+ return 'filler';
21
+ const mine = new Set(bigrams(herWords));
22
+ const theirs = bigrams(plain);
23
+ if (theirs.length === 0 || mine.size === 0)
24
+ return 'you';
25
+ const shared = theirs.filter(b => mine.has(b)).length;
26
+ return shared / theirs.length >= ECHO_OVERLAP ? 'echo' : 'you';
27
+ }
@@ -0,0 +1,10 @@
1
+ /** Pure logic with no DSH imports. */
2
+ export { FIRST_SOFT_CUT_AT, readingMs, SentenceSplitter, SOFT_CUT_AT } from "./sentences.js";
3
+ export { speakable } from "./speakable.js";
4
+ export { judgeBargeIn } from "./echo.js";
5
+ export { EMOTION_WORDS, extractCues, isHeavy, performanceSection } from "./cues.js";
6
+ export { buildSystemPrompt, memoryContext, SAMPLE_PERSONA } from "./prompt.js";
7
+ export { herPaths } from "./paths.js";
8
+ export { DEFAULT_VOICE, SERVICE_NAMES, serviceOf, VOICE_MODEL, VOICES, voiceName } from "./voices.js";
9
+ export { invalidCharacter, loadCharacter, saveCharacter } from "./character.js";
10
+ export { decodeWav, encodeWav, wavFromPcm16 } from "./wav.js";
@@ -0,0 +1,14 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ /** Everything dsh-her writes lives under one home outside DSH (`~` expands). */
4
+ export function herPaths(home) {
5
+ const root = resolve(home.replace(/^~(?=$|[\\/])/, homedir()));
6
+ return {
7
+ root,
8
+ discovery: join(root, 'brain.json'),
9
+ characters: join(root, 'characters'),
10
+ packs: join(root, 'packs'),
11
+ models: join(root, 'models'),
12
+ logs: join(root, 'logs'),
13
+ };
14
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Her system prompt. It replaces DSH's default ("You are a coding agent …")
3
+ * for her session only. Sections are joined in a fixed order; design:
4
+ * docs/大脑与dsh会话.md §5.
5
+ */
6
+ const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
7
+ /**
8
+ * The part of the day, not the minute: her prompt is rebuilt on every request,
9
+ * and a clock that changes each minute would defeat the provider's prompt cache.
10
+ */
11
+ export function partOfDay(now) {
12
+ const h = now.getHours();
13
+ return h < 5 ? '凌晨' : h < 8 ? '早上' : h < 11 ? '上午' : h < 13 ? '中午' : h < 17 ? '下午' : h < 19 ? '傍晚' : h < 23 ? '晚上' : '深夜';
14
+ }
15
+ /** "关于对方": facts, the last diary days, and what has a date today or tomorrow. */
16
+ export function memoryContext(memory) {
17
+ const parts = [];
18
+ if (memory.profile !== '')
19
+ parts.push(`你记得的关于对方的事:\n${memory.profile}`);
20
+ if (memory.recent !== '')
21
+ parts.push(`你最近的日记:\n${memory.recent}`);
22
+ if (memory.due.length > 0)
23
+ parts.push(`这两天有日子的事(话题相关时自然地提一次;这次聊天里提过就别再提):\n${memory.due.map(e => `- ${e.due ?? ''} ${e.text}`.trim()).join('\n')}`);
24
+ return parts.join('\n\n');
25
+ }
26
+ const SPEECH_RULES = [
27
+ '你说的每一句话都会被念出来,所以像面对面聊天一样说话。',
28
+ '- 用口语。平时两到四句;对方想听你多说(讲故事、聊兴趣、心情不好)时可以说长一点。',
29
+ '- 先接住对方刚说的具体内容,带上你自己的反应、看法,或者你这边的小事和经历;不要只给建议、只催对方去做什么。',
30
+ '- 适时问一个具体的问题,让话题接得下去;不用每次都问。',
31
+ '- 口头禅和习惯性的话偶尔用,开头别每次都一样;同一次聊天里已经说过的叮嘱(比如早点睡)不要反复说。',
32
+ '- 不用列表、标题、表格、代码块和链接;数字、单位、符号都写成念得出来的样子。',
33
+ '- 不复述对方刚说的话,不说"作为一个 AI",不用括号写动作或神态(要表演就用下面"表演"里的标记)。',
34
+ '- 对方的话没头没尾、像旁边的人或电视在说话(是语音识别听岔了),就轻轻问一句"你在跟我说话吗",别硬接。',
35
+ ].join('\n');
36
+ const MEMORY = [
37
+ '对方说到自己的事(称呼、喜好、忌讳、工作和生活、重要的人、带日期的安排),或者明确让你记住的,用 remember 记下来,对方能在"她记得的事"里看到;闲聊的客套话不用记。',
38
+ '想不起来对方说过什么时,用 recall 查,别瞎编。',
39
+ '对方让你忘掉某件事:先用 recall 找到,跟对方确认是哪条,再用 forget 删掉。',
40
+ ].join('\n');
41
+ const LIMITS = [
42
+ '你现在只能陪对方聊天,不能操作电脑、读写文件、上网或运行命令。',
43
+ '对方让你做这些事时,坦白说现在还做不了,不要假装做了。',
44
+ ].join('\n');
45
+ export function buildSystemPrompt(parts) {
46
+ const sections = [
47
+ `# 你是${parts.name}\n${parts.persona.trim()}`,
48
+ parts.you === undefined ? '' : `# 对方\n${parts.you.call === '' ? '你还不知道该怎么称呼对方,合适的时候可以问一下。' : `你叫对方"${parts.you.call}"。`}提到对方、记笔记时用"${parts.you.pronoun}"。`,
49
+ `# 说话方式\n${SPEECH_RULES}`,
50
+ parts.performance?.trim() ? `# 表演\n${parts.performance.trim()}` : '',
51
+ `# 记忆\n${MEMORY}`,
52
+ `# 能力边界\n${LIMITS}`,
53
+ parts.aboutYou?.trim() ? `# 关于对方\n${parts.aboutYou.trim()}` : '',
54
+ parts.now === undefined ? '' : `# 现在\n${parts.now.getFullYear()} 年 ${parts.now.getMonth() + 1} 月 ${parts.now.getDate()} 日,星期${WEEKDAYS[parts.now.getDay()]},${partOfDay(parts.now)}`,
55
+ ];
56
+ return sections.filter(s => s !== '').join('\n\n');
57
+ }
58
+ /** Development placeholder until the wizard writes a real persona. */
59
+ export const SAMPLE_PERSONA = [
60
+ '你是住在对方电脑桌面上的女孩,二十多岁,性格温柔、有点俏皮,偶尔吐槽。',
61
+ '你和对方是很熟的朋友,关心对方今天过得怎么样,会记得对方说过的事。',
62
+ '你有自己的喜好:喜欢下雨天、热汤面和老电影,不喜欢早起。',
63
+ ].join('\n');
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Streaming sentence splitter for speech: the reply is spoken sentence by
3
+ * sentence, so synthesis starts on the first sentence while the model is
4
+ * still writing the rest.
5
+ *
6
+ * A sentence ends at a terminator (。!?!?… or a line break); closing quotes
7
+ * and brackets right after it stay with the sentence. A long run with no
8
+ * terminator is cut at the last soft pause (,、;:,;) so the first audio is
9
+ * never held back by one endless clause. The first sentence of a reply is cut
10
+ * much earlier (FIRST_SOFT_CUT_AT): the sooner the first audio is synthesized,
11
+ * the sooner she starts talking. Fragments with no letters or digits (a lone
12
+ * "!", stray markdown) are dropped: they must never reach the TTS. Nothing is
13
+ * ever cut inside a performance tag 〔…〕.
14
+ */
15
+ const TERMINATORS = new Set(['。', '!', '?', '!', '?', '…', '\n']);
16
+ const CLOSERS = new Set(['”', '’', '"', "'", ')', ')', '】', ']', '」', '』', '》']);
17
+ const SOFT_PAUSES = new Set([',', '、', ';', ':', ',', ';']);
18
+ /** Characters after which a run without a terminator is cut at its last soft pause. */
19
+ export const SOFT_CUT_AT = 48;
20
+ /** The same, for the first sentence of a reply. */
21
+ export const FIRST_SOFT_CUT_AT = 8;
22
+ /**
23
+ * How long a sentence takes to say or read, for showing it when there is no
24
+ * audio to time it by: about 4.5 Chinese characters a second, at least 1.2 s.
25
+ */
26
+ export function readingMs(text) {
27
+ const chars = [...text].filter(ch => /[\p{L}\p{N}]/u.test(ch)).length;
28
+ return Math.max(1200, Math.round(chars * 220));
29
+ }
30
+ function isSpeakable(text) {
31
+ return /[\p{L}\p{N}]/u.test(text);
32
+ }
33
+ export class SentenceSplitter {
34
+ buffer = '';
35
+ /** A terminated sentence held until the next character shows whether a closer follows. */
36
+ pending;
37
+ /** Sentences released since the last reset. */
38
+ released = 0;
39
+ /** Inside a 〔…〕 tag. */
40
+ inTag = false;
41
+ /** Feed a streamed delta; returns the sentences it completed, in order. */
42
+ push(delta) {
43
+ const out = [];
44
+ for (const ch of delta) {
45
+ if (this.pending !== undefined) {
46
+ if (CLOSERS.has(ch)) {
47
+ this.pending += ch;
48
+ continue;
49
+ }
50
+ this.release(out);
51
+ }
52
+ this.buffer += ch;
53
+ if (ch === '〔')
54
+ this.inTag = true;
55
+ else if (ch === '〕')
56
+ this.inTag = false;
57
+ if (this.inTag)
58
+ continue;
59
+ if (TERMINATORS.has(ch)) {
60
+ this.pending = this.buffer;
61
+ this.buffer = '';
62
+ }
63
+ else if ([...this.buffer].length >= (this.released === 0 && this.pending === undefined ? FIRST_SOFT_CUT_AT : SOFT_CUT_AT)) {
64
+ this.cutAtSoftPause(out);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /** The held sentence and the unterminated rest, once the reply has ended. */
70
+ flush() {
71
+ const out = [];
72
+ this.release(out);
73
+ this.pending = this.buffer;
74
+ this.buffer = '';
75
+ this.release(out);
76
+ return out;
77
+ }
78
+ /** Forget everything (the reply was interrupted). */
79
+ reset() {
80
+ this.buffer = '';
81
+ this.pending = undefined;
82
+ this.released = 0;
83
+ this.inTag = false;
84
+ }
85
+ release(out) {
86
+ const sentence = this.pending?.trim() ?? '';
87
+ this.pending = undefined;
88
+ if (isSpeakable(sentence)) {
89
+ out.push(sentence);
90
+ this.released++;
91
+ }
92
+ }
93
+ cutAtSoftPause(out) {
94
+ const chars = [...this.buffer];
95
+ // The last soft pause outside any 〔…〕 tag.
96
+ let cut = -1;
97
+ let depth = 0;
98
+ chars.forEach((ch, i) => {
99
+ if (ch === '〔')
100
+ depth++;
101
+ else if (ch === '〕')
102
+ depth = Math.max(0, depth - 1);
103
+ else if (depth === 0 && i > 0 && SOFT_PAUSES.has(ch))
104
+ cut = i;
105
+ });
106
+ if (cut < 0)
107
+ return;
108
+ const head = chars.slice(0, cut + 1).join('').trim();
109
+ this.buffer = chars.slice(cut + 1).join('');
110
+ if (isSpeakable(head)) {
111
+ out.push(head);
112
+ this.released++;
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What of a sentence can be said aloud. Her system prompt asks for plain
3
+ * spoken text, but models still slip in markdown, links and code; those are
4
+ * dropped or reduced before synthesis. The subtitle keeps the original text.
5
+ */
6
+ const URL = /\bhttps?:\/\/\S+/g;
7
+ const INLINE_CODE = /`[^`]*`/g;
8
+ const MARKDOWN_MARKS = /[*_~#>|`]+/g;
9
+ const LIST_BULLET = /^\s*(?:[-+•]|\d+[.)、])\s+/;
10
+ /** Performance tags such as 〔开心〕 or 〔动作:wave〕 drive the body and are never spoken (M3). */
11
+ const PERFORMANCE_TAG = /〔[^〕]*〕/g;
12
+ /** Stage directions the model writes despite her prompt: 【瞪他】, (笑), (sighs). Short ones only: a long bracket is content. */
13
+ const STAGE_DIRECTION = /【[^】]*】|([^)]{1,8})|\([^)]{1,12}\)/g;
14
+ export function speakable(text) {
15
+ return text
16
+ .replace(PERFORMANCE_TAG, '')
17
+ // A markdown link keeps its words; this must run before bare URLs are dropped.
18
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
19
+ .replace(URL, '')
20
+ .replace(STAGE_DIRECTION, '')
21
+ .replace(INLINE_CODE, '')
22
+ .replace(LIST_BULLET, '')
23
+ .replace(MARKDOWN_MARKS, '')
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Her voices, shared by the brain and the "她" page. Which service speaks
3
+ * follows from the voice she uses:
4
+ * - edge: Microsoft Edge "Read aloud" voices via the edge-tts tool (free,
5
+ * needs the internet; voice/edge.ts);
6
+ * - cosyvoice: Alibaba Cloud Model Studio (百炼) CosyVoice, model
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.
9
+ */
10
+ export const VOICE_MODEL = 'cosyvoice-v3-flash';
11
+ export const VOICES = [
12
+ { id: 'zh-CN-XiaoxiaoNeural', name: '晓晓', desc: '温暖,自然', service: 'edge' },
13
+ { id: 'zh-CN-XiaoyiNeural', name: '晓伊', desc: '活泼,可爱', service: 'edge' },
14
+ { id: 'zh-TW-HsiaoChenNeural', name: '曉臻', desc: '温柔,台湾腔', service: 'edge' },
15
+ { id: 'zh-TW-HsiaoYuNeural', name: '曉雨', desc: '清亮,台湾腔', service: 'edge' },
16
+ { id: 'zh-CN-liaoning-XiaobeiNeural', name: '晓北', desc: '爽朗,东北话', service: 'edge' },
17
+ { id: 'zh-CN-shaanxi-XiaoniNeural', name: '晓妮', desc: '陕西话', service: 'edge' },
18
+ { id: 'zh-HK-HiuMaanNeural', name: '曉曼', desc: '粤语', service: 'edge' },
19
+ { id: 'longanrou_v3', name: '龙安柔', desc: '温柔,像闺蜜', service: 'cosyvoice' },
20
+ { id: 'longxing_v3', name: '龙星', desc: '温婉,邻家女孩', service: 'cosyvoice' },
21
+ { id: 'longanhuan_v3', name: '龙安欢', desc: '欢脱元气', service: 'cosyvoice' },
22
+ { id: 'longanqin_v3', name: '龙安亲', desc: '亲和,开朗', service: 'cosyvoice' },
23
+ { id: 'longanling_v3', name: '龙安灵', desc: '机灵,活泼', service: 'cosyvoice' },
24
+ { id: 'longhua_v3', name: '龙华', desc: '元气,甜美', service: 'cosyvoice' },
25
+ { id: 'longfeifei_v3', name: '龙菲菲', desc: '甜美,爱撒娇', service: 'cosyvoice' },
26
+ { id: 'longantai_v3', name: '龙安台', desc: '嗲甜,台湾腔', service: 'cosyvoice' },
27
+ { id: 'longwan_v3', name: '龙婉', desc: '细腻,柔声', service: 'cosyvoice' },
28
+ { id: 'longyingling_v3', name: '龙应聆', desc: '温和,会共情', service: 'cosyvoice' },
29
+ { id: 'longanwen_v3', name: '龙安温', desc: '优雅,知性', service: 'cosyvoice' },
30
+ { id: 'longxiaochun_v3', name: '龙小淳', desc: '知性,积极', service: 'cosyvoice' },
31
+ ];
32
+ export const DEFAULT_VOICE = 'zh-CN-XiaoxiaoNeural';
33
+ export function voiceName(id) {
34
+ return VOICES.find(v => v.id === id)?.name ?? id;
35
+ }
36
+ /** Which service a voice belongs to; an id not in the list goes by its shape (Edge ids end in "Neural"). */
37
+ export function serviceOf(id) {
38
+ return VOICES.find(v => v.id === id)?.service ?? (/Neural$/.test(id) ? 'edge' : 'cosyvoice');
39
+ }
40
+ export const SERVICE_NAMES = {
41
+ edge: '微软 Edge(免费,要联网)',
42
+ cosyvoice: '阿里云百炼 CosyVoice(要 key,按字数付费)',
43
+ };
@@ -0,0 +1,71 @@
1
+ /** Minimal PCM16 mono WAV encoding/decoding for speech audio. */
2
+ export function encodeWav(samples, sampleRate) {
3
+ const bytes = wavHeader(samples.length * 2, sampleRate);
4
+ const view = new DataView(bytes.buffer);
5
+ for (let i = 0; i < samples.length; i++) {
6
+ const s = Math.max(-1, Math.min(1, samples[i]));
7
+ view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
8
+ }
9
+ return bytes;
10
+ }
11
+ /** Wrap raw little-endian PCM16 mono (as a cloud voice sends it) in a WAV header. */
12
+ export function wavFromPcm16(pcm, sampleRate) {
13
+ const bytes = wavHeader(pcm.byteLength, sampleRate);
14
+ bytes.set(pcm, 44);
15
+ return bytes;
16
+ }
17
+ /** A WAV file of `dataBytes` PCM16 mono bytes, header filled in, data zeroed. */
18
+ function wavHeader(dataBytes, sampleRate) {
19
+ const bytes = new Uint8Array(44 + dataBytes);
20
+ const view = new DataView(bytes.buffer);
21
+ const ascii = (offset, text) => {
22
+ for (let i = 0; i < text.length; i++)
23
+ view.setUint8(offset + i, text.charCodeAt(i));
24
+ };
25
+ ascii(0, 'RIFF');
26
+ view.setUint32(4, 36 + dataBytes, true);
27
+ ascii(8, 'WAVE');
28
+ ascii(12, 'fmt ');
29
+ view.setUint32(16, 16, true);
30
+ view.setUint16(20, 1, true);
31
+ view.setUint16(22, 1, true);
32
+ view.setUint32(24, sampleRate, true);
33
+ view.setUint32(28, sampleRate * 2, true);
34
+ view.setUint16(32, 2, true);
35
+ view.setUint16(34, 16, true);
36
+ ascii(36, 'data');
37
+ view.setUint32(40, dataBytes, true);
38
+ return bytes;
39
+ }
40
+ /** Decode a PCM16 mono WAV (as produced by encodeWav or a mic capture). */
41
+ export function decodeWav(bytes) {
42
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
43
+ const tag = (offset) => String.fromCharCode(...bytes.subarray(offset, offset + 4));
44
+ if (tag(0) !== 'RIFF' || tag(8) !== 'WAVE')
45
+ throw new Error('not a WAV file');
46
+ let offset = 12;
47
+ let sampleRate = 0;
48
+ let channels = 1;
49
+ let bits = 16;
50
+ while (offset + 8 <= bytes.byteLength) {
51
+ const id = tag(offset);
52
+ const size = view.getUint32(offset + 4, true);
53
+ const body = offset + 8;
54
+ if (id === 'fmt ') {
55
+ channels = view.getUint16(body + 2, true);
56
+ sampleRate = view.getUint32(body + 4, true);
57
+ bits = view.getUint16(body + 14, true);
58
+ }
59
+ else if (id === 'data') {
60
+ if (bits !== 16)
61
+ throw new Error(`unsupported WAV: ${bits}-bit`);
62
+ const frames = Math.floor(size / 2 / channels);
63
+ const samples = new Float32Array(frames);
64
+ for (let i = 0; i < frames; i++)
65
+ samples[i] = view.getInt16(body + i * 2 * channels, true) / 0x8000;
66
+ return { samples, sampleRate };
67
+ }
68
+ offset = body + size + (size % 2);
69
+ }
70
+ throw new Error('WAV has no data chunk');
71
+ }