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,39 @@
1
+ /**
2
+ * Body protocol v1: the messages between the brain (this plugin) and a body
3
+ * (the Electron desktop app, later Unity or a phone). Design: docs/身体协议.md.
4
+ *
5
+ * Text frames carry one JSON message each. Binary frames carry audio: a 4-byte
6
+ * big-endian header holding the stream id, then the payload. The audio frame
7
+ * is sent first, then the `speak.seg` or `user.utterance` naming its stream.
8
+ *
9
+ * This file has no runtime dependencies so the body can import the same source.
10
+ */
11
+ export const PROTOCOL_VERSION = 1;
12
+ /** Parse one text frame. Unknown `t` values pass through so newer peers degrade instead of failing. */
13
+ export function parseMessage(raw) {
14
+ let value;
15
+ try {
16
+ value = JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ if (typeof value !== 'object' || value === null)
22
+ return undefined;
23
+ const t = value.t;
24
+ return typeof t === 'string' && t !== '' ? value : undefined;
25
+ }
26
+ /** Prefix an audio payload with its stream id. */
27
+ export function encodeAudioFrame(stream, payload) {
28
+ const frame = new Uint8Array(4 + payload.byteLength);
29
+ new DataView(frame.buffer).setUint32(0, stream >>> 0);
30
+ frame.set(payload, 4);
31
+ return frame;
32
+ }
33
+ /** Split a binary frame into its stream id and payload; undefined when it is too short. */
34
+ export function decodeAudioFrame(frame) {
35
+ if (frame.byteLength < 4)
36
+ return undefined;
37
+ const stream = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0);
38
+ return { stream, payload: frame.subarray(4) };
39
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The brain's side of the body protocol: a WebSocket server on loopback.
3
+ * Design: docs/身体协议.md §2–3.
4
+ *
5
+ * - Port: the preferred one, else the next free ones, else any free port.
6
+ * - Token: created once and kept in `<home>/token` (mode 600); a body proves
7
+ * itself with it in `hello`, never in the URL.
8
+ * - Discovery: `<home>/brain.json` (mode 600) says where the brain listens and
9
+ * carries the token, so a body started by hand can find it; it is rewritten
10
+ * on every start because the port may change, and removed on stop.
11
+ * - Heartbeat: a body pings every 15 s; one silent for 45 s is dropped.
12
+ * - Audio: a binary frame (4-byte stream id + payload) comes first, then the
13
+ * JSON message naming its stream; the server pairs them, both directions.
14
+ *
15
+ * The server knows nothing about DSH or sessions; the plugin wires it.
16
+ */
17
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
18
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { dirname } from 'node:path';
20
+ import { WebSocketServer } from 'ws';
21
+ import { decodeAudioFrame, encodeAudioFrame, parseMessage, PROTOCOL_VERSION } from "../protocol/index.js";
22
+ export const DEFAULT_PORT = 38100;
23
+ const PORT_TRIES = 20;
24
+ const DEAD_AFTER_MS = 45_000;
25
+ /** Audio frames waiting for the message that names them, per body; older ones are dropped. */
26
+ const MAX_PENDING_AUDIO = 8;
27
+ function writePrivate(file, text) {
28
+ mkdirSync(dirname(file), { recursive: true });
29
+ writeFileSync(file, text, { mode: 0o600 });
30
+ try {
31
+ chmodSync(file, 0o600);
32
+ }
33
+ catch {
34
+ // Windows: the user's profile directory is already private.
35
+ }
36
+ }
37
+ /** The brain's token: read, or created on first start. */
38
+ export function loadToken(file) {
39
+ if (existsSync(file)) {
40
+ const token = readFileSync(file, 'utf8').trim();
41
+ if (token.length >= 32)
42
+ return token;
43
+ }
44
+ const token = randomBytes(32).toString('base64url');
45
+ writePrivate(file, `${token}\n`);
46
+ return token;
47
+ }
48
+ function sameToken(a, b) {
49
+ const x = Buffer.from(a);
50
+ const y = Buffer.from(b);
51
+ return x.byteLength === y.byteLength && timingSafeEqual(x, y);
52
+ }
53
+ async function listen(port) {
54
+ return new Promise((resolve, reject) => {
55
+ const server = new WebSocketServer({ host: '127.0.0.1', port, maxPayload: 32 * 1024 * 1024 });
56
+ server.once('listening', () => resolve(server));
57
+ server.once('error', reject);
58
+ });
59
+ }
60
+ export class BrainServer {
61
+ server;
62
+ token = '';
63
+ nextId = 1;
64
+ clients = new Map();
65
+ reaper;
66
+ lastSeen = new Map();
67
+ options;
68
+ constructor(options) {
69
+ this.options = options;
70
+ }
71
+ get port() {
72
+ const address = this.server?.address();
73
+ return typeof address === 'object' && address !== null ? address.port : undefined;
74
+ }
75
+ get url() {
76
+ return this.port === undefined ? undefined : `ws://127.0.0.1:${this.port}`;
77
+ }
78
+ get bodies() {
79
+ return [...this.clients.values()];
80
+ }
81
+ async start() {
82
+ this.token = loadToken(this.options.tokenFile);
83
+ const first = this.options.preferredPort ?? DEFAULT_PORT;
84
+ let lastError;
85
+ for (let i = 0; i <= PORT_TRIES && this.server === undefined; i++) {
86
+ try {
87
+ this.server = await listen(i === PORT_TRIES ? 0 : first + i);
88
+ }
89
+ catch (error) {
90
+ lastError = error;
91
+ }
92
+ }
93
+ if (this.server === undefined)
94
+ throw lastError;
95
+ this.server.on('connection', socket => this.accept(socket));
96
+ this.reaper = setInterval(() => this.reap(), 15_000);
97
+ this.reaper.unref();
98
+ const discovery = { url: this.url, token: this.token, pid: process.pid, protocol: PROTOCOL_VERSION, startedAt: new Date().toISOString() };
99
+ writePrivate(this.options.discoveryFile, `${JSON.stringify(discovery, null, 2)}\n`);
100
+ this.options.logger?.info(`dsh-her: brain listening on ${this.url}`);
101
+ return discovery;
102
+ }
103
+ broadcast(message) {
104
+ for (const client of this.clients.values())
105
+ client.send(message);
106
+ }
107
+ /** Send audio and then the message naming its stream to every body. */
108
+ broadcastWithAudio(message, stream, payload) {
109
+ for (const client of this.clients.values()) {
110
+ client.sendAudio(stream, payload);
111
+ client.send(message);
112
+ }
113
+ }
114
+ async stop() {
115
+ clearInterval(this.reaper);
116
+ const server = this.server;
117
+ this.server = undefined;
118
+ for (const socket of this.clients.keys())
119
+ socket.close(1001, 'brain stopping');
120
+ this.clients.clear();
121
+ try {
122
+ const current = JSON.parse(readFileSync(this.options.discoveryFile, 'utf8'));
123
+ if (current.pid === process.pid)
124
+ rmSync(this.options.discoveryFile, { force: true });
125
+ }
126
+ catch {
127
+ // already gone
128
+ }
129
+ await new Promise(resolve => server === undefined ? resolve() : server.close(() => resolve()));
130
+ }
131
+ accept(socket) {
132
+ this.lastSeen.set(socket, Date.now());
133
+ const send = (message) => {
134
+ if (socket.readyState === socket.OPEN)
135
+ socket.send(JSON.stringify(message));
136
+ };
137
+ const sendAudio = (stream, payload) => {
138
+ if (socket.readyState === socket.OPEN)
139
+ socket.send(encodeAudioFrame(stream, payload));
140
+ };
141
+ const pendingAudio = new Map();
142
+ // The first message must be a valid hello within 10 s.
143
+ const helloTimer = setTimeout(() => socket.close(4000, 'no hello'), 10_000);
144
+ socket.on('message', (data, isBinary) => {
145
+ this.lastSeen.set(socket, Date.now());
146
+ if (isBinary) {
147
+ if (!this.clients.has(socket))
148
+ return;
149
+ const bytes = data instanceof Buffer ? data : Buffer.concat(Array.isArray(data) ? data : [Buffer.from(data)]);
150
+ const frame = decodeAudioFrame(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
151
+ if (frame === undefined)
152
+ return;
153
+ pendingAudio.set(frame.stream, frame.payload);
154
+ while (pendingAudio.size > MAX_PENDING_AUDIO)
155
+ pendingAudio.delete(pendingAudio.keys().next().value);
156
+ return;
157
+ }
158
+ const message = parseMessage(data.toString());
159
+ if (message === undefined)
160
+ return;
161
+ const client = this.clients.get(socket);
162
+ if (client === undefined) {
163
+ clearTimeout(helloTimer);
164
+ this.admit(socket, message, send, sendAudio);
165
+ return;
166
+ }
167
+ if (message.t === 'ping') {
168
+ client.send({ t: 'pong', at: message.at });
169
+ return;
170
+ }
171
+ const stream = message.stream;
172
+ const audio = typeof stream === 'number' ? pendingAudio.get(stream) : undefined;
173
+ if (typeof stream === 'number')
174
+ pendingAudio.delete(stream);
175
+ try {
176
+ this.options.message(client, message, audio);
177
+ }
178
+ catch (error) {
179
+ this.options.logger?.warn(`dsh-her: body message ${message.t} failed (${error.message})`);
180
+ }
181
+ });
182
+ socket.on('close', () => {
183
+ clearTimeout(helloTimer);
184
+ this.lastSeen.delete(socket);
185
+ const client = this.clients.get(socket);
186
+ this.clients.delete(socket);
187
+ if (client !== undefined)
188
+ this.options.closed?.(client);
189
+ });
190
+ socket.on('error', () => socket.close());
191
+ }
192
+ admit(socket, message, send, sendAudio) {
193
+ if (message.t !== 'hello') {
194
+ socket.close(4001, 'hello first');
195
+ return;
196
+ }
197
+ if (typeof message.token !== 'string' || !sameToken(message.token, this.token)) {
198
+ send({ t: 'reject', reason: 'token', message: '令牌不对:请重新配对,或让 dsh 重新拉起身体' });
199
+ socket.close(4003, 'token');
200
+ return;
201
+ }
202
+ if (message.v !== PROTOCOL_VERSION) {
203
+ send({ t: 'reject', reason: 'version', message: `协议版本不一致(身体 ${message.v},大脑 ${PROTOCOL_VERSION}),请更新` });
204
+ socket.close(4004, 'version');
205
+ return;
206
+ }
207
+ const client = { id: this.nextId++, hello: message, send, sendAudio };
208
+ this.clients.set(socket, client);
209
+ send(this.options.welcome(client));
210
+ this.options.logger?.info(`dsh-her: body #${client.id} connected (${message.body.kind} ${message.body.version}, ${message.body.os})`);
211
+ }
212
+ reap() {
213
+ const now = Date.now();
214
+ for (const [socket, seen] of this.lastSeen) {
215
+ if (now - seen > DEAD_AFTER_MS)
216
+ socket.terminate();
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,32 @@
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
+ export type Pronoun = '他' | '她' | 'TA';
8
+ export interface Character {
9
+ name: string;
10
+ persona: string;
11
+ /** What she calls you; empty = she has not been told. */
12
+ callYou: string;
13
+ /** How she refers to you in her notes and memories. */
14
+ pronoun: Pronoun;
15
+ /** A CosyVoice voice id (voices.ts), and how fast she talks. */
16
+ voice: {
17
+ id: string;
18
+ speed: number;
19
+ };
20
+ /** Her model; undefined = the plugin config's, else DSH's default. `effort` empty = the route's default. */
21
+ model?: {
22
+ provider: string;
23
+ model: string;
24
+ effort?: string;
25
+ };
26
+ /** The wizard has been completed once. */
27
+ onboarded: boolean;
28
+ }
29
+ export declare function loadCharacter(dir: string, defaults: Character): Character;
30
+ export declare function saveCharacter(dir: string, character: Character): void;
31
+ /** Check a patch from the page; returns the error message, or undefined when it is fine. */
32
+ export declare function invalidCharacter(c: Character): string | undefined;
@@ -0,0 +1,20 @@
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
+ import type { Cue, PackSummary } from '../protocol/index.ts';
11
+ /** Chinese names for the emotions a pack may have. */
12
+ export declare const EMOTION_WORDS: Record<string, string>;
13
+ /** Light actions are acted inline; heavy ones (outfits, stored lines) go through the play_action tool. */
14
+ export declare function isHeavy(action: PackSummary['actions'][number]): boolean;
15
+ export declare function extractCues(text: string, pack: PackSummary | undefined): {
16
+ text: string;
17
+ cues: Cue[];
18
+ };
19
+ /** The "表演" section of her system prompt, for what this pack can do; empty without a pack. */
20
+ export declare function performanceSection(pack: PackSummary | undefined): string;
@@ -0,0 +1,10 @@
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
+ export type EchoVerdict = 'you' | 'echo' | 'filler';
10
+ export declare function judgeBargeIn(heard: string, herWords: string): EchoVerdict;
@@ -0,0 +1,10 @@
1
+ /** Pure logic with no DSH imports. */
2
+ export { FIRST_SOFT_CUT_AT, readingMs, SentenceSplitter, SOFT_CUT_AT } from './sentences.ts';
3
+ export { speakable } from './speakable.ts';
4
+ export { judgeBargeIn, type EchoVerdict } from './echo.ts';
5
+ export { EMOTION_WORDS, extractCues, isHeavy, performanceSection } from './cues.ts';
6
+ export { buildSystemPrompt, memoryContext, SAMPLE_PERSONA, type MemoryContext, type PromptParts } from './prompt.ts';
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';
9
+ export { invalidCharacter, loadCharacter, saveCharacter, type Character, type Pronoun } from './character.ts';
10
+ export { decodeWav, encodeWav, wavFromPcm16 } from './wav.ts';
@@ -0,0 +1,11 @@
1
+ export interface HerPaths {
2
+ root: string;
3
+ /** brain.json: where the brain listens and its token, for a body the user started by hand. */
4
+ discovery: string;
5
+ characters: string;
6
+ packs: string;
7
+ models: string;
8
+ logs: string;
9
+ }
10
+ /** Everything dsh-her writes lives under one home outside DSH (`~` expands). */
11
+ export declare function herPaths(home: string): HerPaths;
@@ -0,0 +1,39 @@
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
+ export interface PromptParts {
7
+ name: string;
8
+ /** Who she is, how she talks, how she relates to you (from the character settings). */
9
+ persona: string;
10
+ /** Inline performance tags she may use, generated from the body's pack; empty when no body is connected. */
11
+ performance?: string;
12
+ /** What she remembers, already rendered (memoryContext); empty until there is any. */
13
+ aboutYou?: string;
14
+ /** Today, so "tomorrow" and "next Wednesday" mean something. */
15
+ now?: Date;
16
+ /** What she calls you, and the pronoun for you in her notes. */
17
+ you?: {
18
+ call: string;
19
+ pronoun: string;
20
+ };
21
+ }
22
+ export interface MemoryContext {
23
+ profile: string;
24
+ recent: string;
25
+ due: Array<{
26
+ text: string;
27
+ due?: string;
28
+ }>;
29
+ }
30
+ /**
31
+ * The part of the day, not the minute: her prompt is rebuilt on every request,
32
+ * and a clock that changes each minute would defeat the provider's prompt cache.
33
+ */
34
+ export declare function partOfDay(now: Date): string;
35
+ /** "关于对方": facts, the last diary days, and what has a date today or tomorrow. */
36
+ export declare function memoryContext(memory: MemoryContext): string;
37
+ export declare function buildSystemPrompt(parts: PromptParts): string;
38
+ /** Development placeholder until the wizard writes a real persona. */
39
+ export declare const SAMPLE_PERSONA: string;
@@ -0,0 +1,40 @@
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
+ /** Characters after which a run without a terminator is cut at its last soft pause. */
16
+ export declare const SOFT_CUT_AT = 48;
17
+ /** The same, for the first sentence of a reply. */
18
+ export declare const FIRST_SOFT_CUT_AT = 8;
19
+ /**
20
+ * How long a sentence takes to say or read, for showing it when there is no
21
+ * audio to time it by: about 4.5 Chinese characters a second, at least 1.2 s.
22
+ */
23
+ export declare function readingMs(text: string): number;
24
+ export declare class SentenceSplitter {
25
+ private buffer;
26
+ /** A terminated sentence held until the next character shows whether a closer follows. */
27
+ private pending;
28
+ /** Sentences released since the last reset. */
29
+ private released;
30
+ /** Inside a 〔…〕 tag. */
31
+ private inTag;
32
+ /** Feed a streamed delta; returns the sentences it completed, in order. */
33
+ push(delta: string): string[];
34
+ /** The held sentence and the unterminated rest, once the reply has ended. */
35
+ flush(): string[];
36
+ /** Forget everything (the reply was interrupted). */
37
+ reset(): void;
38
+ private release;
39
+ private cutAtSoftPause;
40
+ }
@@ -0,0 +1,6 @@
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
+ export declare function speakable(text: string): string;
@@ -0,0 +1,23 @@
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 declare const VOICE_MODEL = "cosyvoice-v3-flash";
11
+ export type VoiceService = 'edge' | 'cosyvoice';
12
+ export interface VoiceOption {
13
+ id: string;
14
+ name: string;
15
+ desc: string;
16
+ service: VoiceService;
17
+ }
18
+ export declare const VOICES: readonly VoiceOption[];
19
+ export declare const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural";
20
+ export declare function voiceName(id: string): string;
21
+ /** Which service a voice belongs to; an id not in the list goes by its shape (Edge ids end in "Neural"). */
22
+ export declare function serviceOf(id: string): VoiceService;
23
+ export declare const SERVICE_NAMES: Record<VoiceService, string>;
@@ -0,0 +1,9 @@
1
+ /** Minimal PCM16 mono WAV encoding/decoding for speech audio. */
2
+ export declare function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array;
3
+ /** Wrap raw little-endian PCM16 mono (as a cloud voice sends it) in a WAV header. */
4
+ export declare function wavFromPcm16(pcm: Uint8Array, sampleRate: number): Uint8Array;
5
+ /** Decode a PCM16 mono WAV (as produced by encodeWav or a mic capture). */
6
+ export declare function decodeWav(bytes: Uint8Array): {
7
+ samples: Float32Array;
8
+ sampleRate: number;
9
+ };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 形象工坊 (docs/形象工坊与多场景.md §4): a look as a project the user fills in,
3
+ * and the prompts and files made from it. Pure: the brain writes the files
4
+ * (dsh/workshop.ts), the "她" page edits the project.
5
+ *
6
+ * Prompts come in three layers:
7
+ * - rules: fixed, by kind (desk pet or scene);
8
+ * - who she is: one sentence from the character card, the same in every
9
+ * image and every clip, so she stays the same person;
10
+ * - the action: one per clip, preset or the user's own.
11
+ */
12
+ export declare const PROJECT_FORMAT = "dsh-her-workshop@1";
13
+ export declare const PROJECT_FILE = "\u5F62\u8C61.json";
14
+ export declare const REFERENCE_FILE = "\u53C2\u8003\u56FE.png";
15
+ export declare const DESK_DIR = "\u684C\u5BA0";
16
+ export declare const sceneDir: (scene: {
17
+ name: string;
18
+ }) => string;
19
+ /** A folder or file name from what the user typed. */
20
+ export declare function safeName(text: string): string;
21
+ export interface CharacterCard {
22
+ gender: string;
23
+ age: string;
24
+ skin: string;
25
+ hairStyle: string;
26
+ hairColor: string;
27
+ build: string;
28
+ outfit: string;
29
+ accessories: string;
30
+ style: string;
31
+ /** Anything else ("左眼下有颗小痣"). */
32
+ extra: string;
33
+ /** The sentence as the user edited it; empty: made from the fields. */
34
+ sentence: string;
35
+ }
36
+ /** Choices for each field of the card ("自己写" is always possible). Ages are adults only. */
37
+ export declare const CARD_CHOICES: Record<Exclude<keyof CharacterCard, 'extra' | 'sentence'>, readonly string[]>;
38
+ export declare const CARD_LABELS: Record<keyof CharacterCard, string>;
39
+ export declare const DEFAULT_CARD: CharacterCard;
40
+ /** "二十多岁的成年女性,自然肤色,深棕色长卷发,戴细框眼镜,穿家居服,身材匀称" — or the user's own sentence. */
41
+ export declare function describe(card: CharacterCard): string;
42
+ /** Green clothes would be keyed away with the green screen: then the background is grey. */
43
+ export declare function screenColor(card: CharacterCard): {
44
+ name: string;
45
+ hex: string;
46
+ };
47
+ export type Pose = 'stand' | 'sit' | 'lie';
48
+ export declare const POSE_WORDS: Record<Pose, string>;
49
+ export interface ClipPlan {
50
+ /** File name without .mp4: `<pose>_<role>_<nn>`. */
51
+ file: string;
52
+ action: string;
53
+ /** Part of the minimum: idle and two talks. */
54
+ required?: boolean;
55
+ /** Left out of the task (the user unticked it). */
56
+ off?: boolean;
57
+ }
58
+ export interface ProjectScene {
59
+ id: string;
60
+ name: string;
61
+ /** What the place looks like ("温馨的卧室,木质床头柜和台灯,暖色灯光"). */
62
+ place: string;
63
+ pose: Pose;
64
+ hours?: string;
65
+ user?: 'active' | 'idle';
66
+ clips: ClipPlan[];
67
+ }
68
+ /** The tool the clips are made with. `manual` is any other tool, described in `custom`. */
69
+ export type Generator = 'minimax' | 'comfyui' | 'jimeng' | 'kling' | 'hailuo' | 'wanx' | 'vidu' | 'manual';
70
+ export interface GeneratorInfo {
71
+ name: string;
72
+ /** One line in the picker. */
73
+ label: string;
74
+ /** The settings every clip uses, written at the top of each 提示词.txt. */
75
+ settings: string;
76
+ /** How the clips get made, in the last card. */
77
+ how: string;
78
+ }
79
+ export declare const GENERATORS: Record<Exclude<Generator, 'manual'>, GeneratorInfo>;
80
+ /** For `manual`: the tool's name, its settings, and words every clip's prompt starts with (a LoRA trigger word, a style). */
81
+ export interface CustomGenerator {
82
+ name: string;
83
+ settings: string;
84
+ prefix: string;
85
+ }
86
+ export declare const MANUAL_LABEL = "\u624B\u5DE5\u586B\u5199\uFF08\u522B\u7684\u5DE5\u5177\uFF0C\u81EA\u5DF1\u5199\u540D\u5B57\u548C\u8BBE\u7F6E\uFF09";
87
+ /** The project's tool, a custom one filled in from `custom`. */
88
+ export declare function generatorInfo(project: Pick<LookProject, 'generator' | 'custom'>): GeneratorInfo;
89
+ export interface LookProject {
90
+ format: typeof PROJECT_FORMAT;
91
+ id: string;
92
+ name: string;
93
+ card: CharacterCard;
94
+ desk: {
95
+ on: boolean;
96
+ clips: ClipPlan[];
97
+ };
98
+ scenes: ProjectScene[];
99
+ /** The scene when no rule matches (an id in `scenes`). */
100
+ default?: string;
101
+ generator: Generator;
102
+ /** Filled in when `generator` is `manual`. */
103
+ custom?: CustomGenerator;
104
+ createdAt: string;
105
+ }
106
+ /** The preset clips for a pose: the minimum ticked, the rest offered unticked. */
107
+ export declare function presetClips(pose: Pose): ClipPlan[];
108
+ /** A clip of the user's own: an action name from what they typed (`<pose>_<name>_01`), not clashing with the others. */
109
+ export declare function customClip(pose: Pose, existing: readonly ClipPlan[], action: string): ClipPlan;
110
+ export declare function newProject(id: string, name: string, now?: Date): LookProject;
111
+ export declare function newScene(id: string, name: string, pose?: Pose): ProjectScene;
112
+ /** Fixed rules every clip carries, for its kind and the generator. */
113
+ export declare function rules(project: LookProject, kind: 'desk' | 'scene', scene?: ProjectScene): string;
114
+ export declare const NEGATIVE = "\u6362\u8138, \u8EAB\u4EFD\u6F02\u79FB, \u53D1\u578B\u53D8\u5316, \u670D\u88C5\u53D8\u5316, \u5E74\u9F84\u53D8\u5316, \u591A\u4F59\u4EBA\u7269, \u591A\u4F59\u624B\u6307, \u624B\u90E8\u7578\u5F62, \u80A2\u4F53\u626D\u66F2, \u811A\u6B65\u79FB\u52A8, \u8D70\u51FA\u753B\u9762, \u77AC\u79FB, \u955C\u5934\u79FB\u52A8, \u53D8\u7126, \u5207\u955C, \u80CC\u666F\u53D8\u5316, \u5149\u7EBF\u95EA\u70C1, \u9634\u5F71\u51FA\u73B0, \u6587\u5B57, \u5B57\u5E55, \u6C34\u5370, logo, \u5938\u5F20\u8868\u60C5, \u5361\u901A\u5316, \u5851\u6599\u76AE\u80A4, \u6A21\u7CCA, \u4F4E\u6E05";
115
+ /** The full prompt of one clip: rules, who she is, the action. */
116
+ export declare function clipPrompt(project: LookProject, kind: 'desk' | 'scene', clip: ClipPlan, scene?: ProjectScene): string;
117
+ /** The reference picture: her on a plain screen, full length. It is also the desk pet's anchor. */
118
+ export declare function referencePrompt(project: LookProject): string;
119
+ /** A scene's full-screen photo, made with the reference picture so she stays the same person. */
120
+ export declare function scenePhotoPrompt(project: LookProject, scene: ProjectScene, screen?: {
121
+ width: number;
122
+ height: number;
123
+ }): string;
124
+ /** Every clip to make, with where it goes and its prompt. */
125
+ export declare function plannedClips(project: LookProject): Array<{
126
+ part: string;
127
+ folder: string;
128
+ first: string;
129
+ clip: ClipPlan;
130
+ prompt: string;
131
+ }>;
132
+ /** 提示词.txt of one part (the desk pet, or a scene): every clip's prompt, ready to copy. */
133
+ export declare function promptsFile(project: LookProject, part: 'desk' | ProjectScene): string;
134
+ /** 生成清单.md: every clip with its first frame, where it goes and its prompt, for any tool. */
135
+ export declare function clipList(project: LookProject, root: string): string;
136
+ /** ComfyUI生成清单.json: the same list for a script that drives a ComfyUI workflow. */
137
+ export declare function comfyuiList(project: LookProject, root: string): string;
138
+ /** The task file for MiniMax Code: one uniform setting, at most 5 at a time, download as each finishes. */
139
+ export declare function minimaxTask(project: LookProject, root: string): string;
140
+ /** An age that is clearly grown up: 二十多岁, 三十岁左右, 25 岁, 成年 … */
141
+ export declare function isAdult(age: string): boolean;
142
+ /** Check a project read from disk or sent by the page. */
143
+ export declare function validateProject(value: unknown): LookProject;
144
+ /** The workshop's cards, in order. */
145
+ export type WorkshopCard = 'look' | 'reference' | 'desk' | 'scene' | 'import';
146
+ /**
147
+ * Example pictures and clips each card shows, by file name under
148
+ * `examples/workshop/` (in the plugin; a file of the same name in
149
+ * `<home>/examples/workshop/` wins). A missing one shows as a placeholder
150
+ * naming the file, until the finished showroom's material is put there.
151
+ */
152
+ export declare const EXAMPLE_SLOTS: Record<WorkshopCard, ReadonlyArray<{
153
+ file: string;
154
+ label: string;
155
+ }>>;
156
+ export declare const EXAMPLE_FILES: ReadonlySet<string>;