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,47 @@
1
+ /**
2
+ * Alibaba Cloud Model Studio (百炼) CosyVoice over its WebSocket API: one
3
+ * connection, kept open and reused, one task per sentence (run-task →
4
+ * continue-task → finish-task; audio arrives as binary frames, then
5
+ * task-finished). Tasks run one at a time, in order; the speech queue is
6
+ * serial anyway, and a preview simply waits its turn.
7
+ *
8
+ * Audio is requested as raw PCM16 and wrapped in WAV here, so its duration
9
+ * is exact. Docs: https://help.aliyun.com/zh/model-studio/cosyvoice-websocket-api
10
+ */
11
+ /** The account cannot be used: no key or a refused one (`key`), or no quota left (`quota`). Retrying soon will not help. */
12
+ export declare class CloudAccountError extends Error {
13
+ readonly reason: 'key' | 'quota';
14
+ constructor(message: string, reason?: 'key' | 'quota');
15
+ }
16
+ export interface CloudAudio {
17
+ /** WAV bytes. */
18
+ audio: Uint8Array;
19
+ durationMs: number;
20
+ /** From sending the task to the last audio frame. */
21
+ ms: number;
22
+ }
23
+ export declare class CosyVoice {
24
+ private socket;
25
+ private opening;
26
+ private task;
27
+ private chain;
28
+ private readonly model;
29
+ private readonly getKey;
30
+ private readonly endpoints;
31
+ /** The endpoint that last accepted the key; tried first. */
32
+ private endpoint;
33
+ constructor(model: string, getKey: () => Promise<string | undefined>, endpoints?: readonly string[]);
34
+ /** Open the connection ahead of time (when you start talking), so her first sentence does not pay for it. */
35
+ warm(): void;
36
+ /** One sentence. Rejects with CloudAccountError when there is no key or it is refused. */
37
+ synthesize(text: string, voice: string, rate: number): Promise<CloudAudio>;
38
+ close(): void;
39
+ private run;
40
+ private once;
41
+ private connect;
42
+ private open;
43
+ private dial;
44
+ private onMessage;
45
+ private settle;
46
+ private fail;
47
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Microsoft Edge "Read aloud" voices (晓晓, 晓伊 …) through the open-source
3
+ * command-line tool edge-tts (https://github.com/rany2/edge-tts), the same
4
+ * way dsh-audiobook does it: we never talk to the service ourselves.
5
+ *
6
+ * Where the tool comes from, first found wins:
7
+ * 1. one already installed by dsh-her (`<home>/edge-tts`), dsh-audiobook or
8
+ * dsh-tingdong (`~/.dsh/<plugin>/edge-tts`), or on the PATH via Homebrew;
9
+ * 2. otherwise it is installed in the background into `<home>/edge-tts`,
10
+ * a Python virtual environment of its own, built with the computer's
11
+ * Python 3.8+ if it has one;
12
+ * 3. without one (a new Mac has only a /usr/bin/python3 that asks to install
13
+ * the developer tools), uv (https://github.com/astral-sh/uv, one pinned,
14
+ * checksummed download) fetches a Python of its own into `<home>/uv`
15
+ * and builds the environment with it. No dialogs, nothing system-wide.
16
+ * Deleting `<home>/edge-tts` (and `<home>/uv`) uninstalls it.
17
+ *
18
+ * One process per sentence, so each pays for a fresh connection (about 2.5 s
19
+ * measured on 2026-10-29); the speech queue synthesizes ahead to hide it.
20
+ * Needs the internet; not an official Microsoft API, so it may stop working.
21
+ */
22
+ export interface EdgeAudio {
23
+ /** MP3 bytes. */
24
+ audio: Uint8Array;
25
+ durationMs: number;
26
+ ms: number;
27
+ }
28
+ export type EdgeStatus = 'missing' | 'installing' | 'ready' | 'failed';
29
+ /** `--rate` for a speed factor: 1.2 → "+20%". */
30
+ export declare function edgeRate(speed: number): string;
31
+ export declare class EdgeVoice {
32
+ private readonly ownDir;
33
+ private installing;
34
+ private state;
35
+ private error;
36
+ private readonly viaUv;
37
+ /** `ownDir`: where dsh-her installs its own copy (`<home>/edge-tts`). `viaUv`: skip the computer's Python (tests). */
38
+ constructor(ownDir: string, options?: {
39
+ viaUv?: boolean;
40
+ });
41
+ get status(): {
42
+ status: EdgeStatus;
43
+ error?: string;
44
+ };
45
+ /** The edge-tts command, or undefined when there is none yet. */
46
+ bin(): string | undefined;
47
+ /** Make sure the tool is there, installing it once if needed. */
48
+ ensure(): Promise<void>;
49
+ /** One sentence as MP3. */
50
+ synthesize(text: string, voice: string, speed: number): Promise<EdgeAudio>;
51
+ private install;
52
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Parent side of the local speech recognition child process. Requests are answered in order of completion,
3
+ * matched by id. If the child dies, pending requests fail and the next call
4
+ * starts a fresh one.
5
+ *
6
+ * Inside the DSH desktop app `process.execPath` is Electron, so the child is
7
+ * started with ELECTRON_RUN_AS_NODE. The sherpa native library directory is
8
+ * put on the loader path, as sherpa-onnx documents.
9
+ */
10
+ import type { WorkerInit } from './worker.ts';
11
+ export declare class LocalVoice {
12
+ private child;
13
+ private nextId;
14
+ private readonly pending;
15
+ private ready;
16
+ private readonly init;
17
+ constructor(init: WorkerInit);
18
+ /** Load the models (once); resolves with how long loading took. */
19
+ start(): Promise<{
20
+ loadMs: number;
21
+ }>;
22
+ recognize(samples: Float32Array, sampleRate: number): Promise<{
23
+ text: string;
24
+ lang: string;
25
+ ms: number;
26
+ }>;
27
+ stop(): void;
28
+ private spawn;
29
+ private request;
30
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Local speech recognition models (sherpa-onnx). They are downloaded on first
3
+ * use into `<home>/models`, never shipped in the package. Her own voice is in
4
+ * the cloud (cosyvoice.ts). Design: docs/语音方案.md.
5
+ */
6
+ export interface ModelSpec {
7
+ id: 'asr' | 'vad';
8
+ /** Directory (or file, for a bare .onnx) it unpacks to under the models dir. */
9
+ name: string;
10
+ url: string;
11
+ bytes: number;
12
+ archive: boolean;
13
+ }
14
+ export declare const MODELS: readonly ModelSpec[];
15
+ export declare function modelPath(modelsDir: string, spec: ModelSpec): string;
16
+ export declare function missingModels(modelsDir: string): ModelSpec[];
17
+ /**
18
+ * Download and unpack one model. Writes to a temporary name first, so an
19
+ * interrupted download never looks installed.
20
+ */
21
+ export declare function fetchModel(modelsDir: string, spec: ModelSpec, onProgress?: (received: number, total: number) => void): Promise<void>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Turns her replies into audio sentence by sentence, in order.
3
+ *
4
+ * The next few sentences are synthesized at once (AHEAD), so while one plays
5
+ * the following ones are already on their way; a slow voice service only
6
+ * makes the first sentence of a reply wait. Sentences are started and
7
+ * emitted strictly in order, across replies, so the first sentence of a reply
8
+ * is never queued behind a later one. Each result is emitted as soon as it
9
+ * and everything before it are ready (the body queues playback), and a reply's
10
+ * end is emitted after its last sentence. Cancelling a reply drops what has
11
+ * not been emitted yet; synthesis already running for it is thrown away.
12
+ *
13
+ * A sentence whose synthesis fails is still emitted, without audio, so the
14
+ * subtitle shows and the reply goes on.
15
+ */
16
+ import type { Cue } from '../protocol/index.ts';
17
+ export interface Synthesized {
18
+ audio: Uint8Array;
19
+ durationMs: number;
20
+ format: 'wav' | 'mp3';
21
+ }
22
+ export interface SpeechOut {
23
+ sentence(uid: string, seq: number, text: string, audio: Synthesized | undefined, cues: Cue[]): void;
24
+ end(uid: string): void;
25
+ }
26
+ /** What a reply has sent so far: enough to say how much of it was heard when it is cut off. */
27
+ export interface SentSentence {
28
+ seq: number;
29
+ text: string;
30
+ durationMs: number;
31
+ }
32
+ export declare class SpeechQueue {
33
+ private readonly jobs;
34
+ private running;
35
+ private readonly cancelled;
36
+ private readonly sent;
37
+ private readonly synth;
38
+ private readonly out;
39
+ constructor(synth: (text: string) => Promise<Synthesized | undefined>, out: SpeechOut);
40
+ say(uid: string, seq: number, text: string, cues?: Cue[]): void;
41
+ end(uid: string): void;
42
+ cancel(uid: string): void;
43
+ /** Sentences of a reply that went out, in order. */
44
+ sentOf(uid: string): SentSentence[];
45
+ /** Resolves when nothing is queued or being synthesized. */
46
+ idle(): Promise<void>;
47
+ /** Start synthesizing the first AHEAD sentences still to emit. */
48
+ private startAhead;
49
+ private pump;
50
+ }
51
+ /**
52
+ * How much of a reply was heard when it was cut off: every sentence before
53
+ * the one playing, plus the share of that one its played time covers.
54
+ */
55
+ export declare function heardText(sent: SentSentence[], seq: number, playedMs: number): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Her voice for the brain.
3
+ *
4
+ * - Listening: local speech recognition (sherpa-onnx SenseVoice in a child
5
+ * process, engine.ts). Its model is downloaded in the background on first
6
+ * use; until then she only reads what you type.
7
+ * - Speaking: the service of the voice she uses (core/voices.ts): Microsoft
8
+ * Edge voices through the edge-tts tool (edge.ts, the default), or Alibaba
9
+ * Cloud Model Studio CosyVoice (cosyvoice.ts). When the service cannot
10
+ * speak (tool not installed yet, no key, no quota, offline), a sentence goes
11
+ * out without audio and shows as text; a key added later is picked up
12
+ * without restarting.
13
+ * - Cache: short sentences ("嗯。" "好呀。") are kept, so the most common
14
+ * answers start at once.
15
+ */
16
+ import { type VoiceService } from '../core/index.ts';
17
+ import type { Synthesized } from './speech-queue.ts';
18
+ export interface VoiceConfig {
19
+ /** false: she only writes (text only). */
20
+ enabled: boolean;
21
+ /** Voice id (src/core/voices.ts); its service speaks. */
22
+ voice: string;
23
+ speed: number;
24
+ /** CPU threads for local speech recognition. */
25
+ threads: number;
26
+ }
27
+ /** Listening (local recognition). */
28
+ export type VoiceStatus = 'off' | 'downloading' | 'loading' | 'ready' | 'failed';
29
+ /** Speaking, with the service of her current voice. */
30
+ export type SpeakingStatus = 'off' | 'checking' | 'installing' | 'ready' | 'no-key' | 'no-quota' | 'failed';
31
+ type Logger = {
32
+ info(message: string): void;
33
+ warn(message: string): void;
34
+ };
35
+ export declare class Voice {
36
+ private asr;
37
+ private listening;
38
+ private speakingState;
39
+ private speakingError;
40
+ private keyCheckedAt;
41
+ private readonly cloud;
42
+ private readonly edge;
43
+ private readonly cache;
44
+ private readonly modelsDir;
45
+ private config;
46
+ private readonly logger;
47
+ /** `edgeDir`: where dsh-her installs its own edge-tts if none is found. */
48
+ constructor(modelsDir: string, edgeDir: string, config: VoiceConfig, logger: Logger);
49
+ /** Listening. */
50
+ get status(): VoiceStatus;
51
+ get speaking(): {
52
+ status: SpeakingStatus;
53
+ service: VoiceService;
54
+ error?: string;
55
+ };
56
+ /** Check the cloud voice and get recognition ready. Never throws; the statuses say how it went. */
57
+ start(): Promise<void>;
58
+ /** You started talking: open the cloud connection now, while your words are recognized. */
59
+ warm(): void;
60
+ /** Change her voice and speed (from the "她" page); takes effect with the next sentence. */
61
+ setVoice(voice: string, speed: number): void;
62
+ /** One sentence in a given voice, for trying voices; not cached. Throws with a message for the page. */
63
+ preview(text: string, voice: string, speed: number): Promise<Synthesized>;
64
+ /** Her sentence as audio, or undefined when she cannot speak right now (it shows as text). */
65
+ synthesize(text: string): Promise<Synthesized | undefined>;
66
+ /** What you said, from 16-bit mono PCM. Empty when nothing was understood. */
67
+ recognize(pcm16: Uint8Array, sampleRate: number): Promise<{
68
+ text: string;
69
+ ms: number;
70
+ }>;
71
+ stop(): void;
72
+ private speak;
73
+ private mark;
74
+ }
75
+ export {};
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The local voice child process: sherpa-onnx speech recognition (SenseVoice),
3
+ * on CPU. It runs apart from DSH so a native crash or
4
+ * a long synthesis never stalls or kills the host. The parent (engine.ts)
5
+ * forks it with advanced IPC serialization, so audio travels as typed arrays.
6
+ */
7
+ export interface WorkerInit {
8
+ asrDir: string;
9
+ threads: number;
10
+ }
11
+ export type WorkerRequest = {
12
+ id: number;
13
+ op: 'init';
14
+ init: WorkerInit;
15
+ } | {
16
+ id: number;
17
+ op: 'asr';
18
+ samples: Float32Array;
19
+ sampleRate: number;
20
+ };
21
+ export type WorkerReply = {
22
+ id: number;
23
+ ok: true;
24
+ op: 'init';
25
+ loadMs: number;
26
+ } | {
27
+ id: number;
28
+ ok: true;
29
+ op: 'asr';
30
+ text: string;
31
+ lang: string;
32
+ ms: number;
33
+ } | {
34
+ id: number;
35
+ ok: false;
36
+ error: string;
37
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The Model Studio (百炼) API key, never kept in a file: the environment
3
+ * variable `DASHSCOPE_API_KEY`, or on macOS the keychain item the user adds with
4
+ *
5
+ * security add-generic-password -a dsh-her -s dsh-her-dashscope -w
6
+ *
7
+ * Read on every connection, so a key added later works without restarting DSH.
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+ export const KEYCHAIN_SERVICE = 'dsh-her-dashscope';
11
+ export function readCloudKey() {
12
+ const fromEnv = process.env.DASHSCOPE_API_KEY?.trim();
13
+ if (fromEnv)
14
+ return Promise.resolve(fromEnv);
15
+ if (process.platform !== 'darwin')
16
+ return Promise.resolve(undefined);
17
+ return new Promise(resolve => {
18
+ execFile('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { timeout: 10_000 }, (error, stdout) => {
19
+ const key = stdout.trim();
20
+ resolve(error === null && key !== '' ? key : undefined);
21
+ });
22
+ });
23
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Alibaba Cloud Model Studio (百炼) CosyVoice over its WebSocket API: one
3
+ * connection, kept open and reused, one task per sentence (run-task →
4
+ * continue-task → finish-task; audio arrives as binary frames, then
5
+ * task-finished). Tasks run one at a time, in order; the speech queue is
6
+ * serial anyway, and a preview simply waits its turn.
7
+ *
8
+ * Audio is requested as raw PCM16 and wrapped in WAV here, so its duration
9
+ * is exact. Docs: https://help.aliyun.com/zh/model-studio/cosyvoice-websocket-api
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import WebSocket from 'ws';
13
+ import { wavFromPcm16 } from "../core/index.js";
14
+ /**
15
+ * Mainland China (Beijing) and international (Singapore): a key works in the
16
+ * region it was made in only, so a refused key is tried on the other one, and
17
+ * the one that took it is kept.
18
+ */
19
+ const ENDPOINTS = ['wss://dashscope.aliyuncs.com/api-ws/v1/inference', 'wss://dashscope-intl.aliyuncs.com/api-ws/v1/inference'];
20
+ const SAMPLE_RATE = 24000;
21
+ const TASK_TIMEOUT_MS = 20_000;
22
+ const CONNECT_TIMEOUT_MS = 8_000;
23
+ /** The account cannot be used: no key or a refused one (`key`), or no quota left (`quota`). Retrying soon will not help. */
24
+ export class CloudAccountError extends Error {
25
+ reason;
26
+ constructor(message, reason = 'key') {
27
+ super(message);
28
+ this.reason = reason;
29
+ }
30
+ }
31
+ /** The cloud answered and refused this sentence: retrying it would not help. */
32
+ class CloudTaskError extends Error {
33
+ }
34
+ export class CosyVoice {
35
+ socket;
36
+ opening;
37
+ task;
38
+ chain = Promise.resolve();
39
+ model;
40
+ getKey;
41
+ endpoints;
42
+ /** The endpoint that last accepted the key; tried first. */
43
+ endpoint;
44
+ constructor(model, getKey, endpoints = ENDPOINTS) {
45
+ this.model = model;
46
+ this.getKey = getKey;
47
+ this.endpoints = endpoints;
48
+ this.endpoint = endpoints[0];
49
+ }
50
+ /** Open the connection ahead of time (when you start talking), so her first sentence does not pay for it. */
51
+ warm() {
52
+ this.connect().catch(() => undefined);
53
+ }
54
+ /** One sentence. Rejects with CloudAccountError when there is no key or it is refused. */
55
+ synthesize(text, voice, rate) {
56
+ const run = this.chain.then(() => this.run(text, voice, rate));
57
+ this.chain = run.catch(() => undefined);
58
+ return run;
59
+ }
60
+ close() {
61
+ this.socket?.close();
62
+ this.socket = undefined;
63
+ this.opening = undefined;
64
+ }
65
+ async run(text, voice, rate) {
66
+ const started = Date.now();
67
+ let pcm;
68
+ try {
69
+ pcm = await this.once(await this.connect(), text, voice, rate);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof CloudAccountError || error instanceof CloudTaskError)
73
+ throw error;
74
+ // A connection the server had already closed: try once more on a fresh one.
75
+ this.close();
76
+ pcm = await this.once(await this.connect(), text, voice, rate);
77
+ }
78
+ return { audio: wavFromPcm16(pcm, SAMPLE_RATE), durationMs: Math.round((pcm.byteLength / 2 / SAMPLE_RATE) * 1000), ms: Date.now() - started };
79
+ }
80
+ once(socket, text, voice, rate) {
81
+ return new Promise((resolve, reject) => {
82
+ const id = randomUUID().replaceAll('-', '');
83
+ const timer = setTimeout(() => this.fail(new Error('云端语音超时')), TASK_TIMEOUT_MS);
84
+ this.task = { id, text, chunks: [], resolve, reject, timer };
85
+ socket.send(JSON.stringify({
86
+ header: { action: 'run-task', task_id: id, streaming: 'duplex' },
87
+ payload: {
88
+ task_group: 'audio', task: 'tts', function: 'SpeechSynthesizer', model: this.model,
89
+ parameters: { text_type: 'PlainText', voice, format: 'pcm', sample_rate: SAMPLE_RATE, volume: 50, rate, pitch: 1 },
90
+ input: {},
91
+ },
92
+ }));
93
+ });
94
+ }
95
+ connect() {
96
+ if (this.socket?.readyState === WebSocket.OPEN)
97
+ return Promise.resolve(this.socket);
98
+ this.opening ??= this.open().finally(() => { this.opening = undefined; });
99
+ return this.opening;
100
+ }
101
+ async open() {
102
+ const key = await this.getKey();
103
+ if (key === undefined)
104
+ throw new CloudAccountError('还没有百炼的 API key');
105
+ let refused;
106
+ for (const endpoint of [this.endpoint, ...this.endpoints.filter(e => e !== this.endpoint)]) {
107
+ try {
108
+ const socket = await this.dial(endpoint, key);
109
+ this.endpoint = endpoint;
110
+ return socket;
111
+ }
112
+ catch (error) {
113
+ if (!(error instanceof CloudAccountError))
114
+ throw error;
115
+ refused = error;
116
+ }
117
+ }
118
+ throw refused;
119
+ }
120
+ dial(endpoint, key) {
121
+ return new Promise((resolve, reject) => {
122
+ const socket = new WebSocket(endpoint, { headers: { Authorization: `bearer ${key}` }, handshakeTimeout: CONNECT_TIMEOUT_MS });
123
+ socket.on('unexpected-response', (_request, response) => {
124
+ const status = response.statusCode ?? 0;
125
+ reject(status === 401 || status === 403 ? new CloudAccountError(`百炼拒绝了这个 key(HTTP ${status})`) : new Error(`连不上百炼(HTTP ${status})`));
126
+ socket.terminate();
127
+ });
128
+ socket.on('open', () => {
129
+ this.socket = socket;
130
+ resolve(socket);
131
+ });
132
+ socket.on('message', (data, isBinary) => this.onMessage(socket, data, isBinary));
133
+ socket.on('error', error => {
134
+ reject(error);
135
+ if (this.socket === socket)
136
+ this.fail(error);
137
+ });
138
+ socket.on('close', () => {
139
+ if (this.socket === socket) {
140
+ this.socket = undefined;
141
+ this.fail(new Error('百炼的连接断了'));
142
+ }
143
+ });
144
+ });
145
+ }
146
+ onMessage(socket, data, isBinary) {
147
+ const task = this.task;
148
+ if (task === undefined)
149
+ return;
150
+ if (isBinary) {
151
+ task.chunks.push(data);
152
+ return;
153
+ }
154
+ let event;
155
+ try {
156
+ event = JSON.parse(data.toString('utf8'));
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ const header = event.header ?? {};
162
+ if (header.task_id !== undefined && header.task_id !== task.id)
163
+ return;
164
+ switch (header.event) {
165
+ case 'task-started':
166
+ socket.send(JSON.stringify({ header: { action: 'continue-task', task_id: task.id, streaming: 'duplex' }, payload: { input: { text: task.text } } }));
167
+ socket.send(JSON.stringify({ header: { action: 'finish-task', task_id: task.id, streaming: 'duplex' }, payload: { input: {} } }));
168
+ break;
169
+ case 'task-finished':
170
+ this.settle();
171
+ task.resolve(Buffer.concat(task.chunks));
172
+ break;
173
+ case 'task-failed': {
174
+ const message = `${header.error_code ?? ''} ${header.error_message ?? ''}`.trim();
175
+ this.fail(/InvalidApiKey|Unauthorized|AccessDenied/i.test(message)
176
+ ? new CloudAccountError(`百炼拒绝了这个 key(${message})`)
177
+ : /AllocationQuota|FreeTierOnly|Arrearage|Throttling\.AllocationQuota/i.test(message)
178
+ ? new CloudAccountError(`百炼的额度用完了(${message})`, 'quota')
179
+ : new CloudTaskError(`云端语音失败:${message || '未知原因'}`));
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ settle() {
185
+ if (this.task !== undefined)
186
+ clearTimeout(this.task.timer);
187
+ this.task = undefined;
188
+ }
189
+ fail(error) {
190
+ const task = this.task;
191
+ this.settle();
192
+ task?.reject(error);
193
+ }
194
+ }