dsh-voice 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +11 -1
- package/README.md +11 -1
- package/lib/config.d.ts +3 -2
- package/lib/config.js +18 -8
- package/lib/edge-tts.d.ts +3 -1
- package/lib/edge-tts.js +52 -14
- package/lib/index.d.ts +4 -4
- package/lib/index.js +4 -4
- package/lib/paths.d.ts +4 -4
- package/lib/paths.js +6 -6
- package/lib/proxy-fetch.d.ts +9 -2
- package/lib/proxy-fetch.js +4 -5
- package/lib/stt.d.ts +9 -1
- package/lib/stt.js +38 -11
- package/lib/tools.js +107 -30
- package/package.json +66 -66
package/README.en.md
CHANGED
|
@@ -11,10 +11,12 @@ DSH (DeepSeek Harness) voice plugin pair: let the agent **speak and listen**.
|
|
|
11
11
|
- **voice_tts**: text-to-speech over the **edge-tts protocol** (Microsoft Edge read-aloud service — free, unlimited, 22+ curated voices)
|
|
12
12
|
- **voice_stt**: speech-to-text over any **OpenAI-compatible ASR endpoint** (Groq / OpenAI / custom)
|
|
13
13
|
- **voice_list**: voice catalog
|
|
14
|
+
- **voice_preview**: batch-generate short preview MP3s to pick a voice by ear
|
|
15
|
+
- **voice_health**: offline self-check of TTS voice / ASR key / endpoint / proxy
|
|
14
16
|
|
|
15
17
|
## Compatibility
|
|
16
18
|
|
|
17
|
-
Verified
|
|
19
|
+
Verified with official `@deepseek-ai/dsh@0.1.5-rc.1` and Node `24.16.0` on 2026-09-11: all 18 components load alongside Modlens, with passing tool-schema, skill-registration and offline read-only invocation checks. Uses the `cordis.patch.yml` + `dsh.bundle.patch` bundle model. Node requirements match this Harness release: 22.19 or later within 22.x, or 24 or later. Live external-service workflows require separate configuration and validation.
|
|
18
20
|
|
|
19
21
|
## Installation
|
|
20
22
|
|
|
@@ -53,6 +55,8 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
|
|
|
53
55
|
| `voice_tts` | Synthesize MP3 from text (free) | `text` required; `voice`/`rate`/`pitch`/`output` optional |
|
|
54
56
|
| `voice_stt` | Transcribe audio to text | `audio` required; `engine`/`model`/`language`/`prompt`/`output` optional |
|
|
55
57
|
| `voice_list` | Curated voice catalog | none |
|
|
58
|
+
| `voice_preview` | Batch-generate short preview MP3s | optional `voices` (≤8) / `text` / `outputDir` |
|
|
59
|
+
| `voice_health` | Offline config self-check | none |
|
|
56
60
|
|
|
57
61
|
### Examples
|
|
58
62
|
|
|
@@ -61,6 +65,8 @@ voice_tts { text: hello world } # outputs voice_out
|
|
|
61
65
|
voice_tts { text: hello, voice: en-US-AriaNeural } # English female voice
|
|
62
66
|
voice_stt { audio: E:\audio\meeting.mp3, language: zh } # transcribe a recording
|
|
63
67
|
voice_list {}
|
|
68
|
+
voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # generate two preview samples
|
|
69
|
+
voice_health {} # self-check TTS / ASR / proxy config
|
|
64
70
|
```
|
|
65
71
|
|
|
66
72
|
## Under the hood
|
|
@@ -81,3 +87,7 @@ pnpm test:integration # opt-in real edge-tts request; network and assertion err
|
|
|
81
87
|
## License
|
|
82
88
|
|
|
83
89
|
MIT
|
|
90
|
+
|
|
91
|
+
## Changelog
|
|
92
|
+
|
|
93
|
+
- **0.3.4 (2026-09-18)**: 修复代理路径 STT 上传 `[object FormData]`、25MB 校验在读入之后、输出目录不存在白烧一次合成、默认输出落宿主 cwd; `exec.signal` 全程透传、preview 改 3 路并发. 测试 55 项.
|
package/README.md
CHANGED
|
@@ -14,10 +14,12 @@ DSH(DeepSeek Harness)语音双件套插件:让 agent **会说话、能听
|
|
|
14
14
|
- **voice_tts**:文字转语音,走 **edge-tts 协议**(微软 Edge 朗读服务,免费无限量,22+ 常用音色)
|
|
15
15
|
- **voice_stt**:语音转文字,走 **OpenAI 兼容 ASR 接口**(Groq / OpenAI / 自定义端点)
|
|
16
16
|
- **voice_list**:音色清单
|
|
17
|
+
- **voice_preview**:音色试听,用一段样例文本批量生成短 MP3,挑音色不用盲选
|
|
18
|
+
- **voice_health**:配置自检,体检 TTS 音色 / ASR 密钥 / 接口地址 / 代理(不联网)
|
|
17
19
|
|
|
18
20
|
## 兼容性
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
已在官方 `@deepseek-ai/dsh@0.1.5-rc.1`、Node `24.16.0` 上验证(2026-09-11):18 个组件与 Modlens 同载,工具 schema、技能注册及离线只读调用检查通过。采用 `cordis.patch.yml` + `dsh.bundle.patch` 组合包模型。Node 要求与该版本 Harness 一致:22.19 及以上的 22.x,或 24 及以上。外部服务的实际业务操作需按各组件配置单独验证。
|
|
21
23
|
|
|
22
24
|
## 安装
|
|
23
25
|
|
|
@@ -56,6 +58,8 @@ dsh plugin --profile web remove dsh-voice
|
|
|
56
58
|
| `voice_tts` | 文字合成 MP3(免费) | `text` 必填;`voice`/`rate`/`pitch`/`output` 可选 |
|
|
57
59
|
| `voice_stt` | 音频转文字 | `audio` 必填;`engine`/`model`/`language`/`prompt`/`output` 可选 |
|
|
58
60
|
| `voice_list` | 常用音色清单 | 无 |
|
|
61
|
+
| `voice_preview` | 音色试听:批量生成短样例 MP3 | `voices`(≤8 个)/ `text` / `outputDir` 可选 |
|
|
62
|
+
| `voice_health` | 配置自检(不联网) | 无 |
|
|
59
63
|
|
|
60
64
|
### 示例
|
|
61
65
|
|
|
@@ -64,6 +68,8 @@ voice_tts { text: 今天的 AI 早报来了 } # 晓晓女声
|
|
|
64
68
|
voice_tts { text: hello, voice: en-US-AriaNeural } # 英文女声
|
|
65
69
|
voice_stt { audio: E:\audio\meeting.mp3, language: zh } # 转写会议录音
|
|
66
70
|
voice_list {}
|
|
71
|
+
voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # 生成两个试听样例
|
|
72
|
+
voice_health {} # 自检 TTS / ASR / 代理配置
|
|
67
73
|
```
|
|
68
74
|
|
|
69
75
|
## 硬核细节
|
|
@@ -84,3 +90,7 @@ pnpm test:integration # 显式联网,调用真实 edge-tts;网络或断言
|
|
|
84
90
|
## License
|
|
85
91
|
|
|
86
92
|
MIT
|
|
93
|
+
|
|
94
|
+
## 版本记录
|
|
95
|
+
|
|
96
|
+
- **0.3.4(2026-09-18)**:修复代理路径 STT 上传 `[object FormData]`、25MB 校验在读入之后、输出目录不存在白烧一次合成、默认输出落宿主 cwd;`exec.signal` 全程透传、preview 改 3 路并发。测试 55 项。
|
package/lib/config.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface ResolvedVoiceConfig {
|
|
|
32
32
|
/** ASR 密钥:配置优先,其次环境变量 DSH_VOICE_ASR_KEY。 */
|
|
33
33
|
export declare function resolveAsrApiKey(config: VoiceConfig | undefined, env?: NodeJS.ProcessEnv): string;
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
35
|
+
* 解析并校验配置:非法字段逐项回退到默认值,其余字段照常生效。
|
|
36
|
+
* @param onWarning 非法字段的告警回调,默认打到 console.warn。
|
|
36
37
|
*/
|
|
37
|
-
export declare function resolveConfig(config: VoiceConfig | undefined | null): ResolvedVoiceConfig;
|
|
38
|
+
export declare function resolveConfig(config: VoiceConfig | undefined | null, onWarning?: (message: string) => void): ResolvedVoiceConfig;
|
package/lib/config.js
CHANGED
|
@@ -14,10 +14,15 @@ export function resolveAsrApiKey(config, env = process.env) {
|
|
|
14
14
|
return cfg.asrApiKey.trim();
|
|
15
15
|
return env.DSH_VOICE_ASR_KEY?.trim() ?? '';
|
|
16
16
|
}
|
|
17
|
+
/** 默认告警出口:非法字段逐项回退时点名,避免一个字段拖垮整份配置。 */
|
|
18
|
+
function defaultWarn(message) {
|
|
19
|
+
console.warn('[dsh-voice] ' + message);
|
|
20
|
+
}
|
|
17
21
|
/**
|
|
18
|
-
*
|
|
22
|
+
* 解析并校验配置:非法字段逐项回退到默认值,其余字段照常生效。
|
|
23
|
+
* @param onWarning 非法字段的告警回调,默认打到 console.warn。
|
|
19
24
|
*/
|
|
20
|
-
export function resolveConfig(config) {
|
|
25
|
+
export function resolveConfig(config, onWarning = defaultWarn) {
|
|
21
26
|
const cfg = config ?? {};
|
|
22
27
|
const ttsVoice = typeof cfg.ttsVoice === 'string' && cfg.ttsVoice.trim() !== '' ? cfg.ttsVoice.trim() : 'zh-CN-XiaoxiaoNeural';
|
|
23
28
|
const ttsRate = typeof cfg.ttsRate === 'string' && cfg.ttsRate.trim() !== '' ? cfg.ttsRate.trim() : '+0%';
|
|
@@ -27,14 +32,19 @@ export function resolveConfig(config) {
|
|
|
27
32
|
const asrBaseUrl = (typeof cfg.asrBaseUrl === 'string' && cfg.asrBaseUrl.trim() !== '' ? cfg.asrBaseUrl.trim() : preset?.baseUrl ?? '').replace(/\/$/, '');
|
|
28
33
|
const asrModel = typeof cfg.asrModel === 'string' && cfg.asrModel.trim() !== '' ? cfg.asrModel.trim() : preset?.model ?? '';
|
|
29
34
|
const asrApiKey = resolveAsrApiKey(cfg);
|
|
30
|
-
|
|
31
|
-
if (proxyUrl !== '' && !/^https?:\/\//i.test(proxyUrl))
|
|
32
|
-
|
|
35
|
+
let proxyUrl = typeof cfg.proxyUrl === 'string' && cfg.proxyUrl.trim() !== '' ? cfg.proxyUrl.trim() : '';
|
|
36
|
+
if (proxyUrl !== '' && !/^https?:\/\//i.test(proxyUrl)) {
|
|
37
|
+
onWarning('proxyUrl 配置不合法(必须以 http(s):// 开头,例如 http://127.0.0.1:7890),已忽略该项,其余配置继续生效:' + proxyUrl);
|
|
38
|
+
proxyUrl = '';
|
|
39
|
+
}
|
|
33
40
|
let timeoutMs = 60000;
|
|
34
41
|
if (cfg.timeoutMs !== undefined) {
|
|
35
|
-
if (typeof cfg.timeoutMs !== 'number' || !Number.isFinite(cfg.timeoutMs) || cfg.timeoutMs <= 0)
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
if (typeof cfg.timeoutMs !== 'number' || !Number.isFinite(cfg.timeoutMs) || cfg.timeoutMs <= 0) {
|
|
43
|
+
onWarning('timeoutMs 配置不合法(必须是大于 0 的数字,毫秒),已回退默认值 60000。');
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
timeoutMs = Math.min(10 * 60 * 1000, Math.max(5000, Math.round(cfg.timeoutMs)));
|
|
47
|
+
}
|
|
38
48
|
}
|
|
39
49
|
const overwrite = cfg.overwrite === true;
|
|
40
50
|
return { ttsVoice, ttsRate, ttsPitch, asrEngine, asrBaseUrl, asrApiKey, asrModel, proxyUrl, timeoutMs, overwrite };
|
package/lib/edge-tts.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface TtsDeps {
|
|
|
10
10
|
webSocketFactory?: WebSocketFactory;
|
|
11
11
|
nowSeconds?: () => number;
|
|
12
12
|
proxyUrl?: string;
|
|
13
|
+
/** 取消信号(可选);也可通过 synthesizeSpeech 第 4 个参数传入。 */
|
|
14
|
+
signal?: AbortSignal;
|
|
13
15
|
}
|
|
14
16
|
/** 最小的 WebSocket 面。 */
|
|
15
17
|
export interface WebSocketLike {
|
|
@@ -33,4 +35,4 @@ export declare function buildSsml(options: TtsOptions): string;
|
|
|
33
35
|
* 合成语音,返回 MP3 字节。
|
|
34
36
|
* @throws 文本为空/超长 / 连接失败 / 超时 / 无音频数据时抛中文错误。
|
|
35
37
|
*/
|
|
36
|
-
export declare function synthesizeSpeech(options: TtsOptions, deps?: TtsDeps, timeoutMs?: number): Promise<Buffer>;
|
|
38
|
+
export declare function synthesizeSpeech(options: TtsOptions, deps?: TtsDeps, timeoutMs?: number, signal?: AbortSignal): Promise<Buffer>;
|
package/lib/edge-tts.js
CHANGED
|
@@ -62,7 +62,10 @@ function protocolHeader(path, extra) {
|
|
|
62
62
|
* 合成语音,返回 MP3 字节。
|
|
63
63
|
* @throws 文本为空/超长 / 连接失败 / 超时 / 无音频数据时抛中文错误。
|
|
64
64
|
*/
|
|
65
|
-
export async function synthesizeSpeech(options, deps = {}, timeoutMs = 30000) {
|
|
65
|
+
export async function synthesizeSpeech(options, deps = {}, timeoutMs = 30000, signal) {
|
|
66
|
+
const abortSignal = signal ?? deps.signal;
|
|
67
|
+
if (abortSignal?.aborted === true)
|
|
68
|
+
throw abortSignal.reason;
|
|
66
69
|
if (options.text.trim() === '')
|
|
67
70
|
throw new Error('要合成的文本为空。');
|
|
68
71
|
if (options.text.length > 5000)
|
|
@@ -77,17 +80,56 @@ export async function synthesizeSpeech(options, deps = {}, timeoutMs = 30000) {
|
|
|
77
80
|
const chunks = [];
|
|
78
81
|
let done = false;
|
|
79
82
|
await new Promise((resolvePromise, reject) => {
|
|
80
|
-
const
|
|
83
|
+
const canListen = typeof abortSignal?.addEventListener === 'function';
|
|
84
|
+
let settled = false;
|
|
85
|
+
let timer;
|
|
86
|
+
let onAbort = () => { };
|
|
87
|
+
// 先结算再关 socket:即使 socket.close() 同步触发 close 事件,取消原因也不会被通用错误覆盖。
|
|
88
|
+
const fail = (error) => {
|
|
89
|
+
if (settled)
|
|
90
|
+
return;
|
|
91
|
+
settled = true;
|
|
92
|
+
if (timer !== undefined)
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (canListen)
|
|
95
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
96
|
+
reject(error);
|
|
97
|
+
};
|
|
98
|
+
const finish = () => {
|
|
99
|
+
if (settled)
|
|
100
|
+
return;
|
|
101
|
+
settled = true;
|
|
102
|
+
if (timer !== undefined)
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
if (canListen)
|
|
105
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
106
|
+
try {
|
|
107
|
+
socket.close();
|
|
108
|
+
}
|
|
109
|
+
catch { /* 忽略 */ }
|
|
110
|
+
resolvePromise();
|
|
111
|
+
};
|
|
112
|
+
onAbort = () => {
|
|
113
|
+
const reason = abortSignal?.reason ?? new Error('语音合成已取消。');
|
|
114
|
+
fail(reason);
|
|
115
|
+
try {
|
|
116
|
+
socket.close();
|
|
117
|
+
}
|
|
118
|
+
catch { /* 忽略 */ }
|
|
119
|
+
};
|
|
120
|
+
timer = setTimeout(() => {
|
|
121
|
+
fail(new Error('语音合成超时(' + timeoutMs + ' 毫秒无完整音频),请重试或检查网络。'));
|
|
81
122
|
try {
|
|
82
123
|
socket.close();
|
|
83
124
|
}
|
|
84
125
|
catch { /* 忽略 */ }
|
|
85
|
-
reject(new Error('语音合成超时(' + timeoutMs + ' 毫秒无完整音频),请重试或检查网络。'));
|
|
86
126
|
}, timeoutMs);
|
|
87
|
-
|
|
88
|
-
|
|
127
|
+
if (canListen)
|
|
128
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
129
|
+
if (abortSignal?.aborted === true) {
|
|
130
|
+
onAbort();
|
|
131
|
+
return;
|
|
89
132
|
}
|
|
90
|
-
catch { /* 忽略 */ } resolvePromise(); };
|
|
91
133
|
socket.addEventListener('open', () => {
|
|
92
134
|
const config = protocolHeader('speech.config', { 'Content-Type': 'application/json; charset=utf-8' })
|
|
93
135
|
+ JSON.stringify({
|
|
@@ -103,8 +145,7 @@ export async function synthesizeSpeech(options, deps = {}, timeoutMs = 30000) {
|
|
|
103
145
|
socket.send(ssml);
|
|
104
146
|
}
|
|
105
147
|
catch (error) {
|
|
106
|
-
|
|
107
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
148
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
108
149
|
}
|
|
109
150
|
});
|
|
110
151
|
socket.addEventListener('message', (event) => {
|
|
@@ -112,8 +153,7 @@ export async function synthesizeSpeech(options, deps = {}, timeoutMs = 30000) {
|
|
|
112
153
|
if (typeof data === 'string') {
|
|
113
154
|
if (data.includes('Path:turn.end')) {
|
|
114
155
|
if (chunks.length === 0) {
|
|
115
|
-
|
|
116
|
-
reject(new Error('合成结束但没有收到音频数据(服务端可能拒绝了请求)。'));
|
|
156
|
+
fail(new Error('合成结束但没有收到音频数据(服务端可能拒绝了请求)。'));
|
|
117
157
|
}
|
|
118
158
|
else if (!done) {
|
|
119
159
|
done = true;
|
|
@@ -132,18 +172,16 @@ export async function synthesizeSpeech(options, deps = {}, timeoutMs = 30000) {
|
|
|
132
172
|
}
|
|
133
173
|
});
|
|
134
174
|
socket.addEventListener('error', () => {
|
|
135
|
-
|
|
136
|
-
reject(new Error('edge-tts WebSocket 连接失败。若网络需要特殊代理(梯子),请在 cordis.patch.yml 配置 proxyUrl 后重启。'));
|
|
175
|
+
fail(new Error('edge-tts WebSocket 连接失败。若网络需要特殊代理(梯子),请在 cordis.patch.yml 配置 proxyUrl 后重启。'));
|
|
137
176
|
});
|
|
138
177
|
socket.addEventListener('close', () => {
|
|
139
178
|
if (!done) {
|
|
140
|
-
clearTimeout(timer);
|
|
141
179
|
if (chunks.length > 0) {
|
|
142
180
|
done = true;
|
|
143
181
|
finish();
|
|
144
182
|
}
|
|
145
183
|
else {
|
|
146
|
-
|
|
184
|
+
fail(new Error('edge-tts 连接在收到音频前关闭。'));
|
|
147
185
|
}
|
|
148
186
|
}
|
|
149
187
|
});
|
package/lib/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-voice —— 语音双件套工具插件(node 半身,配置走 cordis.patch.yml)。
|
|
3
3
|
*
|
|
4
|
-
* 插件导出 apply(ctx, config)
|
|
5
|
-
* voice_list)。TTS 走 edge-tts 协议(原生 WebSocket
|
|
6
|
-
* 兼容 ASR 接口(Groq/OpenAI/自定义端点)。
|
|
4
|
+
* 插件导出 apply(ctx, config):注册五个面向模型的工具(voice_tts / voice_stt /
|
|
5
|
+
* voice_list / voice_preview / voice_health)。TTS 走 edge-tts 协议(原生 WebSocket,
|
|
6
|
+
* 零 API 成本);STT 走 OpenAI 兼容 ASR 接口(Groq/OpenAI/自定义端点)。
|
|
7
7
|
*
|
|
8
8
|
* @module dsh-voice
|
|
9
9
|
*/
|
|
@@ -20,7 +20,7 @@ export interface VoicePluginContext {
|
|
|
20
20
|
on?(event: string, listener: () => void): () => void;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* 插件入口:解析配置并注册五个语音工具。
|
|
24
24
|
*/
|
|
25
25
|
export declare function apply(ctx: VoicePluginContext, config?: VoiceConfig | null): void;
|
|
26
26
|
export * from './config.js';
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-voice —— 语音双件套工具插件(node 半身,配置走 cordis.patch.yml)。
|
|
3
3
|
*
|
|
4
|
-
* 插件导出 apply(ctx, config)
|
|
5
|
-
* voice_list)。TTS 走 edge-tts 协议(原生 WebSocket
|
|
6
|
-
* 兼容 ASR 接口(Groq/OpenAI/自定义端点)。
|
|
4
|
+
* 插件导出 apply(ctx, config):注册五个面向模型的工具(voice_tts / voice_stt /
|
|
5
|
+
* voice_list / voice_preview / voice_health)。TTS 走 edge-tts 协议(原生 WebSocket,
|
|
6
|
+
* 零 API 成本);STT 走 OpenAI 兼容 ASR 接口(Groq/OpenAI/自定义端点)。
|
|
7
7
|
*
|
|
8
8
|
* @module dsh-voice
|
|
9
9
|
*/
|
|
@@ -13,7 +13,7 @@ import { buildVoiceTools } from './tools.js';
|
|
|
13
13
|
export const name = 'voice';
|
|
14
14
|
export const inject = ['tools'];
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* 插件入口:解析配置并注册五个语音工具。
|
|
17
17
|
*/
|
|
18
18
|
export function apply(ctx, config) {
|
|
19
19
|
let cfg;
|
package/lib/paths.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export declare function assertAudioFile(input: string): string;
|
|
3
|
-
/**
|
|
4
|
-
export declare function resolveOutputPath(explicit: string | undefined, defaultName: string, overwrite: boolean): string;
|
|
1
|
+
/** 校验音频文件存在且是文件;相对路径按 cwd(会话工作区)解析,返回绝对路径。 */
|
|
2
|
+
export declare function assertAudioFile(input: string, cwd?: string): string;
|
|
3
|
+
/** 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。 */
|
|
4
|
+
export declare function resolveOutputPath(explicit: string | undefined, defaultName: string, overwrite: boolean, cwd?: string): string;
|
package/lib/paths.js
CHANGED
|
@@ -5,17 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, statSync } from 'node:fs';
|
|
7
7
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
8
|
-
/**
|
|
9
|
-
export function assertAudioFile(input) {
|
|
10
|
-
const absolute = resolve(input);
|
|
8
|
+
/** 校验音频文件存在且是文件;相对路径按 cwd(会话工作区)解析,返回绝对路径。 */
|
|
9
|
+
export function assertAudioFile(input, cwd = process.cwd()) {
|
|
10
|
+
const absolute = resolve(cwd, input);
|
|
11
11
|
if (!existsSync(absolute) || !statSync(absolute).isFile()) {
|
|
12
12
|
throw new Error('音频文件不存在:' + input);
|
|
13
13
|
}
|
|
14
14
|
return absolute;
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
17
|
-
export function resolveOutputPath(explicit, defaultName, overwrite) {
|
|
18
|
-
let target = explicit !== undefined && explicit.trim() !== '' ? resolve(explicit.trim()) : resolve(
|
|
16
|
+
/** 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。 */
|
|
17
|
+
export function resolveOutputPath(explicit, defaultName, overwrite, cwd = process.cwd()) {
|
|
18
|
+
let target = explicit !== undefined && explicit.trim() !== '' ? resolve(cwd, explicit.trim()) : resolve(cwd, defaultName);
|
|
19
19
|
if (overwrite || !existsSync(target))
|
|
20
20
|
return target;
|
|
21
21
|
const directory = dirname(target);
|
package/lib/proxy-fetch.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 代理 fetch 一并带出 undici 自己的 FormData 构造器。
|
|
3
|
+
*
|
|
4
|
+
* undici 的 fetch/Response 只认自己那份 FormData(brand symbol 属于该模块),而 Node 的
|
|
5
|
+
* 全局 FormData 是另一份实现:喂错会被当成普通对象序列化成 "[object FormData]"
|
|
6
|
+
* (content-type: text/plain),ASR 接口必回 400 —— 实测见 README 的代理场景。
|
|
3
7
|
*/
|
|
4
|
-
export
|
|
8
|
+
export type ProxyFetch = typeof globalThis.fetch & {
|
|
9
|
+
FormData: typeof globalThis.FormData;
|
|
10
|
+
};
|
|
11
|
+
export declare function createProxyFetch(proxyUrl: string): ProxyFetch;
|
package/lib/proxy-fetch.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 插件级 HTTP 代理:给 TTS 令牌与 ASR 请求一个走指定代理的 fetch,不影响同进程其他插件。
|
|
3
3
|
*/
|
|
4
|
-
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
5
|
-
/**
|
|
6
|
-
* 构造一个把所有请求路由到 proxyUrl 的 fetch。
|
|
7
|
-
*/
|
|
4
|
+
import { FormData as UndiciFormData, ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
8
5
|
export function createProxyFetch(proxyUrl) {
|
|
9
6
|
const agent = new ProxyAgent(proxyUrl);
|
|
10
|
-
|
|
7
|
+
const impl = ((input, init) => undiciFetch(input, { ...init, dispatcher: agent }));
|
|
8
|
+
impl.FormData = UndiciFormData;
|
|
9
|
+
return impl;
|
|
11
10
|
}
|
package/lib/stt.d.ts
CHANGED
|
@@ -5,12 +5,20 @@ export interface SttOptions {
|
|
|
5
5
|
model: string;
|
|
6
6
|
language?: string;
|
|
7
7
|
prompt?: string;
|
|
8
|
+
/** 取消信号(可选);也可通过 transcribe 第 6 个参数传入。 */
|
|
9
|
+
signal?: AbortSignal;
|
|
8
10
|
}
|
|
11
|
+
/** 字节数超过 25MB 时抛错:调用方可在读文件前先按 statSync 大小快速拒绝,避免白读整文件。 */
|
|
12
|
+
export declare function assertAudioSize(bytes: number): void;
|
|
9
13
|
/**
|
|
10
14
|
* 调用 OpenAI 兼容 ASR 接口转写音频。
|
|
11
15
|
* @throws 缺密钥 / 文件过大 / HTTP 错误 / 无文本时抛中文错误。
|
|
12
16
|
*/
|
|
13
|
-
|
|
17
|
+
/** 传给 transcribe 的 fetch:可以像代理那样一并带上自己的 FormData/Blob 构造器。 */
|
|
18
|
+
export type SttFetch = typeof fetch & {
|
|
19
|
+
FormData?: typeof FormData;
|
|
20
|
+
};
|
|
21
|
+
export declare function transcribe(baseUrl: string, apiKey: string, options: SttOptions, fetchImpl?: SttFetch, timeoutMs?: number, signal?: AbortSignal): Promise<{
|
|
14
22
|
text: string;
|
|
15
23
|
model: string;
|
|
16
24
|
}>;
|
package/lib/stt.js
CHANGED
|
@@ -5,20 +5,44 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { extname } from 'node:path';
|
|
7
7
|
const MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
/** 字节数超过 25MB 时抛错:调用方可在读文件前先按 statSync 大小快速拒绝,避免白读整文件。 */
|
|
9
|
+
export function assertAudioSize(bytes) {
|
|
10
|
+
if (bytes > MAX_AUDIO_BYTES)
|
|
11
|
+
throw new Error('音频超过 25MB 上限(ASR 接口限制),请先用 ffmpeg 压缩。');
|
|
12
|
+
}
|
|
13
|
+
/** 已取消则抛出取消原因;作为 await 前后的统一取消检查。 */
|
|
14
|
+
function throwIfAborted(signal) {
|
|
15
|
+
if (signal?.aborted === true)
|
|
16
|
+
throw signal.reason;
|
|
17
|
+
}
|
|
18
|
+
/** 让不认 signal 的 fetch 实现也能在外部取消时立即 reject。 */
|
|
19
|
+
function raceAbort(value, signal) {
|
|
20
|
+
const promise = Promise.resolve(value);
|
|
21
|
+
if (signal === undefined || typeof signal.addEventListener !== 'function')
|
|
22
|
+
return promise;
|
|
23
|
+
if (signal.aborted === true)
|
|
24
|
+
return Promise.reject(signal.reason);
|
|
25
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
26
|
+
const onAbort = () => rejectPromise(signal.reason);
|
|
27
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
28
|
+
promise.then((settled) => { signal.removeEventListener('abort', onAbort); resolvePromise(settled); }, (error) => { signal.removeEventListener('abort', onAbort); rejectPromise(error); });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function transcribe(baseUrl, apiKey, options, fetchImpl = globalThis.fetch, timeoutMs = 120000, signal) {
|
|
32
|
+
const abortSignal = signal ?? options.signal;
|
|
33
|
+
throwIfAborted(abortSignal);
|
|
13
34
|
if (apiKey === '')
|
|
14
35
|
throw new Error('未配置 ASR 密钥:请设置环境变量 DSH_VOICE_ASR_KEY,或在 cordis.patch.yml 的 asrApiKey 配置后重启。');
|
|
15
36
|
if (baseUrl === '')
|
|
16
37
|
throw new Error('未配置 ASR 接口地址(asrEngine=custom 时必须提供 asrBaseUrl)。');
|
|
17
38
|
if (options.audio.length === 0)
|
|
18
39
|
throw new Error('音频文件为空。');
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
assertAudioSize(options.audio.length);
|
|
41
|
+
// 代理 fetch(undici)只认它自己那份 FormData:喂 Node 全局 FormData 会被当成普通
|
|
42
|
+
// 对象序列化成 "[object FormData]"(content-type: text/plain),所以优先用调用方 fetch
|
|
43
|
+
// 附带的构造器。文件部分继续用全局 Blob —— 实测 undici 的 FormData 收它并输出真 multipart。
|
|
44
|
+
const FormDataImpl = fetchImpl.FormData ?? FormData;
|
|
45
|
+
const form = new FormDataImpl();
|
|
22
46
|
const mime = mimeOf(options.filename);
|
|
23
47
|
form.append('file', new Blob([options.audio], { type: mime }), options.filename);
|
|
24
48
|
form.append('model', options.model);
|
|
@@ -26,16 +50,19 @@ export async function transcribe(baseUrl, apiKey, options, fetchImpl = globalThi
|
|
|
26
50
|
form.append('language', options.language);
|
|
27
51
|
if (options.prompt !== undefined && options.prompt !== '')
|
|
28
52
|
form.append('prompt', options.prompt);
|
|
53
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
54
|
+
const requestSignal = abortSignal === undefined ? timeoutSignal : AbortSignal.any([timeoutSignal, abortSignal]);
|
|
29
55
|
let response;
|
|
30
56
|
try {
|
|
31
|
-
response = await fetchImpl(baseUrl + '/audio/transcriptions', {
|
|
57
|
+
response = await raceAbort(fetchImpl(baseUrl + '/audio/transcriptions', {
|
|
32
58
|
method: 'POST',
|
|
33
59
|
headers: { authorization: 'Bearer ' + apiKey },
|
|
34
60
|
body: form,
|
|
35
|
-
signal:
|
|
36
|
-
});
|
|
61
|
+
signal: requestSignal,
|
|
62
|
+
}), abortSignal);
|
|
37
63
|
}
|
|
38
64
|
catch (error) {
|
|
65
|
+
throwIfAborted(abortSignal);
|
|
39
66
|
throw new Error('ASR 请求失败:' + (error instanceof Error ? error.message : String(error)) + '。若接口需要特殊代理(梯子),请在 cordis.patch.yml 配置 proxyUrl 后重启。');
|
|
40
67
|
}
|
|
41
68
|
if (!response.ok) {
|
package/lib/tools.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 五个面向模型的语音工具:voice_tts / voice_stt / voice_list / voice_preview。
|
|
2
|
+
* 五个面向模型的语音工具:voice_tts / voice_stt / voice_list / voice_preview / voice_health。
|
|
3
3
|
*
|
|
4
4
|
* @module dsh-voice/tools
|
|
5
5
|
*/
|
|
6
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { synthesizeSpeech } from './edge-tts.js';
|
|
9
9
|
import { assertAudioFile, resolveOutputPath } from './paths.js';
|
|
10
10
|
import { createProxyFetch } from './proxy-fetch.js';
|
|
11
|
-
import { transcribe } from './stt.js';
|
|
11
|
+
import { assertAudioSize, transcribe } from './stt.js';
|
|
12
12
|
import { isValidVoiceId, VOICES } from './voices.js';
|
|
13
13
|
function compileParameters(spec) {
|
|
14
14
|
const properties = {};
|
|
@@ -21,6 +21,10 @@ function compileParameters(spec) {
|
|
|
21
21
|
node.type = prop.type;
|
|
22
22
|
if (typeof prop?.description === 'string')
|
|
23
23
|
node.description = prop.description;
|
|
24
|
+
if (prop?.enum !== undefined)
|
|
25
|
+
node.enum = prop.enum;
|
|
26
|
+
if (prop?.items !== undefined)
|
|
27
|
+
node.items = prop.items;
|
|
24
28
|
properties[key] = node;
|
|
25
29
|
}
|
|
26
30
|
return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
|
|
@@ -38,6 +42,56 @@ function requiredString(args, key, label) {
|
|
|
38
42
|
throw new Error(label + '(参数 ' + key + ')为必填,请提供非空字符串。');
|
|
39
43
|
return value;
|
|
40
44
|
}
|
|
45
|
+
/** 取本次调用的取消信号;取不到就当未提供。 */
|
|
46
|
+
function execSignal(exec) {
|
|
47
|
+
const signal = exec?.signal;
|
|
48
|
+
return signal !== null && typeof signal === 'object' && typeof signal.aborted === 'boolean' ? signal : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** 已取消则抛出取消原因;作为 await 前后的统一取消检查。 */
|
|
51
|
+
function throwIfAborted(signal) {
|
|
52
|
+
if (signal?.aborted === true)
|
|
53
|
+
throw signal.reason;
|
|
54
|
+
}
|
|
55
|
+
/** 让不认 signal 的实现也能在取消时立即 reject;signal 缺 addEventListener 时只能原样等待。 */
|
|
56
|
+
function raceAbort(value, signal) {
|
|
57
|
+
const promise = Promise.resolve(value);
|
|
58
|
+
if (signal === undefined || typeof signal.addEventListener !== 'function')
|
|
59
|
+
return promise;
|
|
60
|
+
if (signal.aborted === true)
|
|
61
|
+
return Promise.reject(signal.reason);
|
|
62
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
63
|
+
const onAbort = () => rejectPromise(signal.reason);
|
|
64
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
promise.then((settled) => { signal.removeEventListener('abort', onAbort); resolvePromise(settled); }, (error) => { signal.removeEventListener('abort', onAbort); rejectPromise(error); });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** 有界并发:同一时刻最多跑 limit 个任务;取消时停止派发并抛出取消原因。 */
|
|
69
|
+
async function mapLimit(items, limit, signal, worker) {
|
|
70
|
+
const results = new Array(items.length);
|
|
71
|
+
let next = 0;
|
|
72
|
+
const run = async () => {
|
|
73
|
+
for (;;) {
|
|
74
|
+
throwIfAborted(signal);
|
|
75
|
+
const index = next;
|
|
76
|
+
next += 1;
|
|
77
|
+
if (index >= items.length)
|
|
78
|
+
return;
|
|
79
|
+
results[index] = await worker(items[index]);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
/** 取本次调用的会话工作区;harness 从不 chdir,取不到时才回退宿主进程 cwd。 */
|
|
86
|
+
function sessionCwd(exec) {
|
|
87
|
+
const cwd = exec?.agent?.session?.header?.cwd;
|
|
88
|
+
return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : process.cwd();
|
|
89
|
+
}
|
|
90
|
+
/** 写文件前先建父目录,避免合成/转写完成后才因 ENOENT 白烧一次接口调用。 */
|
|
91
|
+
function writeFileWithDir(target, data) {
|
|
92
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
93
|
+
writeFileSync(target, data);
|
|
94
|
+
}
|
|
41
95
|
const voiceItemSchema = {
|
|
42
96
|
type: 'object',
|
|
43
97
|
properties: { id: { type: 'string' }, gender: { type: 'string' }, locale: { type: 'string' } },
|
|
@@ -83,6 +137,8 @@ const previewSchema = {
|
|
|
83
137
|
};
|
|
84
138
|
/** 默认试听文本(中英混合,便于感知发音差异)。 */
|
|
85
139
|
export const DEFAULT_PREVIEW_TEXT = '你好,这是音色试听。Hello, this is a voice preview.';
|
|
140
|
+
/** voice_preview 同时合成的音色数上限:有界并发,避免 8 个音色串行把最坏等待拖成 8 倍超时。 */
|
|
141
|
+
const PREVIEW_CONCURRENCY = 3;
|
|
86
142
|
/**
|
|
87
143
|
* 构建三个工具定义。
|
|
88
144
|
*/
|
|
@@ -119,7 +175,7 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
119
175
|
voice: { type: 'string', description: '音色 id(可选,默认配置的 ttsVoice)。' },
|
|
120
176
|
rate: { type: 'string', description: '语速,如 +20% 或 -10%(可选)。' },
|
|
121
177
|
pitch: { type: 'string', description: '音调,如 +2Hz 或 -1Hz(可选)。' },
|
|
122
|
-
output: { type: 'string', description: '输出 MP3
|
|
178
|
+
output: { type: 'string', description: '输出 MP3 路径(可选,相对路径按会话工作目录解析)。' },
|
|
123
179
|
}),
|
|
124
180
|
output: {
|
|
125
181
|
schema: ttsSchema,
|
|
@@ -128,7 +184,9 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
128
184
|
return [{ type: 'text', text: '语音合成完成:' + rec.output + '(' + rec.bytes + ' 字节,音色 ' + rec.voice + ')' }];
|
|
129
185
|
},
|
|
130
186
|
},
|
|
131
|
-
async execute(rawArgs) {
|
|
187
|
+
async execute(rawArgs, exec) {
|
|
188
|
+
const signal = execSignal(exec);
|
|
189
|
+
throwIfAborted(signal);
|
|
132
190
|
const args = asRecord(rawArgs);
|
|
133
191
|
const text = requiredString(args, 'text', '要合成的文本');
|
|
134
192
|
if (text.length > 5000)
|
|
@@ -142,9 +200,10 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
142
200
|
if (!/^[+-]?\d+(\.\d+)?(%|Hz|st)$/.test(value))
|
|
143
201
|
throw new Error(label + ' 不合法:' + value + '。合法格式如 +10%、-2Hz、+1st。');
|
|
144
202
|
}
|
|
145
|
-
const output = resolveOutputPath(optionalString(args, 'output'), 'voice_output.mp3', cfg.overwrite);
|
|
146
|
-
const audio = await (deps.tts ?? synthesizeSpeech)({ text, voice, rate, pitch }, { proxyUrl: cfg.proxyUrl }, timeout);
|
|
147
|
-
|
|
203
|
+
const output = resolveOutputPath(optionalString(args, 'output'), 'voice_output.mp3', cfg.overwrite, sessionCwd(exec));
|
|
204
|
+
const audio = await raceAbort((deps.tts ?? synthesizeSpeech)({ text, voice, rate, pitch }, { proxyUrl: cfg.proxyUrl }, timeout, signal), signal);
|
|
205
|
+
throwIfAborted(signal);
|
|
206
|
+
writeFileWithDir(output, audio);
|
|
148
207
|
return { output, bytes: audio.length, voice, rate, pitch, textLength: text.length };
|
|
149
208
|
},
|
|
150
209
|
timeoutMs: timeout + 10000,
|
|
@@ -154,11 +213,11 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
154
213
|
description: '语音转文字:调用 OpenAI 兼容 ASR 接口(默认 Groq whisper-large-v3-turbo;可切 openai/custom)。audio 为音频文件路径(mp3/wav/m4a/ogg/flac,≤25MB);language/prompt 可选;output 可把转写文本写成 .txt。密钥用环境变量 DSH_VOICE_ASR_KEY 或配置 asrApiKey。',
|
|
155
214
|
parameters: compileParameters({
|
|
156
215
|
audio: { type: 'string', required: true, description: '音频文件路径(必填)。' },
|
|
157
|
-
engine: { type: 'string', description: '引擎:groq / openai / custom(可选,默认配置值)。' },
|
|
216
|
+
engine: { type: 'string', enum: ['groq', 'openai', 'custom'], description: '引擎:groq / openai / custom(可选,默认配置值)。' },
|
|
158
217
|
model: { type: 'string', description: '模型名(可选,覆盖配置)。' },
|
|
159
218
|
language: { type: 'string', description: '语言提示,如 zh(可选)。' },
|
|
160
219
|
prompt: { type: 'string', description: '提示词(专有名词/术语纠偏,可选)。' },
|
|
161
|
-
output: { type: 'string', description: '把转写文本写成 .txt
|
|
220
|
+
output: { type: 'string', description: '把转写文本写成 .txt 的路径(可选,相对路径按会话工作目录解析)。' },
|
|
162
221
|
}),
|
|
163
222
|
output: {
|
|
164
223
|
schema: sttSchema,
|
|
@@ -167,9 +226,12 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
167
226
|
return [{ type: 'text', text: '转写完成(模型 ' + rec.model + '):' + rec.text }];
|
|
168
227
|
},
|
|
169
228
|
},
|
|
170
|
-
async execute(rawArgs) {
|
|
229
|
+
async execute(rawArgs, exec) {
|
|
230
|
+
const signal = execSignal(exec);
|
|
231
|
+
throwIfAborted(signal);
|
|
171
232
|
const args = asRecord(rawArgs);
|
|
172
|
-
const audioPath = assertAudioFile(requiredString(args, 'audio', '音频文件'));
|
|
233
|
+
const audioPath = assertAudioFile(requiredString(args, 'audio', '音频文件'), sessionCwd(exec));
|
|
234
|
+
assertAudioSize(statSync(audioPath).size);
|
|
173
235
|
const engine = optionalString(args, 'engine') ?? cfg.asrEngine;
|
|
174
236
|
let baseUrl;
|
|
175
237
|
let model;
|
|
@@ -187,18 +249,19 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
187
249
|
}
|
|
188
250
|
else
|
|
189
251
|
throw new Error('engine 必须是 groq / openai / custom 之一(当前:' + engine + ')。');
|
|
190
|
-
const { text } = await (deps.stt ?? transcribe)(baseUrl, cfg.asrApiKey, {
|
|
252
|
+
const { text } = await raceAbort((deps.stt ?? transcribe)(baseUrl, cfg.asrApiKey, {
|
|
191
253
|
audio: readFileSync(audioPath),
|
|
192
254
|
filename: basename(audioPath),
|
|
193
255
|
model,
|
|
194
256
|
language: optionalString(args, 'language'),
|
|
195
257
|
prompt: optionalString(args, 'prompt'),
|
|
196
|
-
}, fetchImpl ?? globalThis.fetch, timeout);
|
|
258
|
+
}, fetchImpl ?? globalThis.fetch, timeout, signal), signal);
|
|
259
|
+
throwIfAborted(signal);
|
|
197
260
|
let transcriptFile = '';
|
|
198
261
|
const output = optionalString(args, 'output');
|
|
199
262
|
if (output !== undefined) {
|
|
200
|
-
const target = resolveOutputPath(output, '', cfg.overwrite);
|
|
201
|
-
|
|
263
|
+
const target = resolveOutputPath(output, '', cfg.overwrite, sessionCwd(exec));
|
|
264
|
+
writeFileWithDir(target, text);
|
|
202
265
|
transcriptFile = target;
|
|
203
266
|
}
|
|
204
267
|
// The output schema declares these as strings — never return null, or
|
|
@@ -209,11 +272,11 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
209
272
|
};
|
|
210
273
|
const voicePreview = {
|
|
211
274
|
name: 'voice_preview',
|
|
212
|
-
description: '音色试听:用一段固定试听文本批量生成短样例 MP3,方便挑选音色。voices 为音色 id 数组(可选,缺省 voice_list 前 4 个,最多 8 个);text 可自定义(≤200
|
|
275
|
+
description: '音色试听:用一段固定试听文本批量生成短样例 MP3,方便挑选音色。voices 为音色 id 数组(可选,缺省 voice_list 前 4 个,最多 8 个);text 可自定义(≤200 字符);样例文件写入输出目录(默认会话工作目录 voice_previews),文件名含音色 id。单个音色失败不阻断其他。',
|
|
213
276
|
parameters: compileParameters({
|
|
214
277
|
voices: { type: 'array', items: { type: 'string' }, description: '音色 id 数组(可选,缺省 voice_list 前 4 个,最多 8 个)。' },
|
|
215
278
|
text: { type: 'string', description: '试听文本(可选,默认中英混合试听句,≤200 字符)。' },
|
|
216
|
-
outputDir: { type: 'string', description: '
|
|
279
|
+
outputDir: { type: 'string', description: '输出目录(可选,默认会话工作目录下 voice_previews)。' },
|
|
217
280
|
}),
|
|
218
281
|
output: {
|
|
219
282
|
schema: previewSchema,
|
|
@@ -233,7 +296,9 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
233
296
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
234
297
|
},
|
|
235
298
|
},
|
|
236
|
-
async execute(rawArgs) {
|
|
299
|
+
async execute(rawArgs, exec) {
|
|
300
|
+
const signal = execSignal(exec);
|
|
301
|
+
throwIfAborted(signal);
|
|
237
302
|
const args = asRecord(rawArgs);
|
|
238
303
|
const rawVoices = Array.isArray(args.voices)
|
|
239
304
|
? args.voices.filter((v) => typeof v === 'string' && v.trim() !== '').map((v) => v.trim())
|
|
@@ -244,24 +309,32 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
244
309
|
const text = optionalString(args, 'text') ?? DEFAULT_PREVIEW_TEXT;
|
|
245
310
|
if (text.length > 200)
|
|
246
311
|
throw new Error('试听文本请控制在 200 字以内(试听要短平快)。');
|
|
247
|
-
const outDir = resolve(optionalString(args, 'outputDir') ?? 'voice_previews');
|
|
312
|
+
const outDir = resolve(sessionCwd(exec), optionalString(args, 'outputDir') ?? 'voice_previews');
|
|
248
313
|
mkdirSync(outDir, { recursive: true });
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
for (const voice of targets) {
|
|
314
|
+
const outcomes = await mapLimit(targets, PREVIEW_CONCURRENCY, signal, async (voice) => {
|
|
315
|
+
throwIfAborted(signal);
|
|
252
316
|
if (!isValidVoiceId(voice)) {
|
|
253
|
-
|
|
254
|
-
continue;
|
|
317
|
+
return { voice, error: '音色 id 不合法(应为 zh-CN-XXXNeural 形式的 edge 音色)' };
|
|
255
318
|
}
|
|
256
319
|
try {
|
|
257
|
-
const audio = await (deps.tts ?? synthesizeSpeech)({ text, voice, rate: cfg.ttsRate, pitch: cfg.ttsPitch }, { proxyUrl: cfg.proxyUrl }, timeout);
|
|
320
|
+
const audio = await raceAbort((deps.tts ?? synthesizeSpeech)({ text, voice, rate: cfg.ttsRate, pitch: cfg.ttsPitch }, { proxyUrl: cfg.proxyUrl }, timeout, signal), signal);
|
|
321
|
+
throwIfAborted(signal);
|
|
258
322
|
const file = join(outDir, 'voice-preview-' + voice.replace(/[^a-zA-Z0-9-]/g, '_') + '.mp3');
|
|
259
323
|
writeFileSync(file, audio);
|
|
260
|
-
|
|
324
|
+
return { voice, output: file, bytes: audio.length };
|
|
261
325
|
}
|
|
262
326
|
catch (error) {
|
|
263
|
-
|
|
327
|
+
throwIfAborted(signal);
|
|
328
|
+
return { voice, error: error instanceof Error ? error.message : String(error) };
|
|
264
329
|
}
|
|
330
|
+
});
|
|
331
|
+
const samples = [];
|
|
332
|
+
const failed = [];
|
|
333
|
+
for (const outcome of outcomes) {
|
|
334
|
+
if (outcome.error !== undefined)
|
|
335
|
+
failed.push({ voice: outcome.voice, error: outcome.error });
|
|
336
|
+
else
|
|
337
|
+
samples.push({ voice: outcome.voice, output: outcome.output, bytes: outcome.bytes });
|
|
265
338
|
}
|
|
266
339
|
return { count: samples.length, samples, failed, text };
|
|
267
340
|
},
|
|
@@ -269,7 +342,7 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
269
342
|
};
|
|
270
343
|
const voiceHealth = {
|
|
271
344
|
name: 'voice_health',
|
|
272
|
-
description: 'dsh-voice 自检:检查 TTS 音色合法性、代理配置与 ASR
|
|
345
|
+
description: 'dsh-voice 自检:检查 TTS 音色合法性、代理配置与 ASR 引擎/接口地址/密钥就绪状态(不发起网络请求)。遇到问题时先运行本工具定位。',
|
|
273
346
|
parameters: compileParameters({}),
|
|
274
347
|
output: {
|
|
275
348
|
schema: { type: 'object', additionalProperties: true },
|
|
@@ -293,6 +366,10 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
293
366
|
ok = false;
|
|
294
367
|
checks.push({ name: '特殊代理', ok: true, detail: cfg.proxyUrl !== '' ? '已配置 ' + cfg.proxyUrl : '未配置' });
|
|
295
368
|
checks.push({ name: 'ASR 引擎', ok: true, detail: cfg.asrEngine });
|
|
369
|
+
const baseUrlOk = cfg.asrBaseUrl !== '';
|
|
370
|
+
checks.push({ name: 'ASR 接口地址', ok: baseUrlOk, detail: baseUrlOk ? cfg.asrBaseUrl : '未配置:asrEngine=custom 时必须提供 asrBaseUrl。' });
|
|
371
|
+
if (!baseUrlOk)
|
|
372
|
+
ok = false;
|
|
296
373
|
const hasKey = cfg.asrApiKey !== '' || typeof process.env.DSH_VOICE_ASR_KEY === 'string' && process.env.DSH_VOICE_ASR_KEY !== '';
|
|
297
374
|
checks.push({ name: 'ASR 密钥', ok: hasKey, detail: hasKey ? '已配置' : '未配置:voice_stt 需要 DSH_VOICE_ASR_KEY 环境变量或配置 asrApiKey' });
|
|
298
375
|
if (!hasKey)
|
package/package.json
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-voice",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "DSH
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "lib/index.js",
|
|
7
|
-
"types": "lib/index.d.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
"types": "./lib/index.d.ts",
|
|
11
|
-
"default": "./lib/index.js"
|
|
12
|
-
},
|
|
13
|
-
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
-
"./package.json": "./package.json"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"lib",
|
|
18
|
-
"cordis.patch.yml",
|
|
19
|
-
"README.md",
|
|
20
|
-
"README.en.md"
|
|
21
|
-
],
|
|
22
|
-
"scripts": {
|
|
23
|
-
"build": "tsc -p tsconfig.json",
|
|
24
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-voice",
|
|
3
|
+
"version": "0.3.4",
|
|
4
|
+
"description": "DSH 语音插件:edge-tts 微软神经语音合成(免费、无需密钥)+ OpenAI 兼容语音转文字,支持插件级代理。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"README.en.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
25
25
|
"test": "pnpm run build && node --test \"test/*.test.mjs\"",
|
|
26
26
|
"test:integration": "pnpm run build && node --test \"integration/*.test.mjs\"",
|
|
27
|
-
"prepublishOnly": "pnpm run build"
|
|
28
|
-
},
|
|
29
|
-
"dsh": {
|
|
30
|
-
"bundle": {
|
|
31
|
-
"patch": "./cordis.patch.yml"
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
"keywords": [
|
|
35
|
-
"dsh",
|
|
36
|
-
"deepseek-harness",
|
|
37
|
-
"plugin",
|
|
38
|
-
"tts",
|
|
39
|
-
"stt",
|
|
40
|
-
"whisper",
|
|
41
|
-
"voice",
|
|
42
|
-
"dsh-plugin"
|
|
43
|
-
],
|
|
44
|
-
"license": "MIT",
|
|
45
|
-
"engines": {
|
|
46
|
-
"node": "
|
|
47
|
-
},
|
|
48
|
-
"dependencies": {
|
|
49
|
-
"https-proxy-agent": "^9.1.0",
|
|
50
|
-
"undici": "^8.10.0",
|
|
51
|
-
"ws": "^8.21.3"
|
|
52
|
-
},
|
|
53
|
-
"devDependencies": {
|
|
54
|
-
"@types/node": "^24.0.0",
|
|
55
|
-
"@types/ws": "^8.18.1",
|
|
56
|
-
"typescript": "^5.6.0"
|
|
57
|
-
},
|
|
58
|
-
"repository": {
|
|
59
|
-
"type": "git",
|
|
60
|
-
"url": "git+https://github.com/STARDUSTLC666/dsh-voice.git"
|
|
61
|
-
},
|
|
62
|
-
"bugs": {
|
|
63
|
-
"url": "https://github.com/STARDUSTLC666/dsh-voice/issues"
|
|
64
|
-
},
|
|
65
|
-
"homepage": "https://github.com/STARDUSTLC666/dsh-voice#readme",
|
|
66
|
-
"author": "stardustlc",
|
|
67
|
-
"packageManager": "pnpm@11.7.0"
|
|
68
|
-
}
|
|
27
|
+
"prepublishOnly": "pnpm run build"
|
|
28
|
+
},
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"dsh",
|
|
36
|
+
"deepseek-harness",
|
|
37
|
+
"plugin",
|
|
38
|
+
"tts",
|
|
39
|
+
"stt",
|
|
40
|
+
"whisper",
|
|
41
|
+
"voice",
|
|
42
|
+
"dsh-plugin"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"https-proxy-agent": "^9.1.0",
|
|
50
|
+
"undici": "^8.10.0",
|
|
51
|
+
"ws": "^8.21.3"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^24.0.0",
|
|
55
|
+
"@types/ws": "^8.18.1",
|
|
56
|
+
"typescript": "^5.6.0"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/STARDUSTLC666/dsh-voice.git"
|
|
61
|
+
},
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/STARDUSTLC666/dsh-voice/issues"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://github.com/STARDUSTLC666/dsh-voice#readme",
|
|
66
|
+
"author": "stardustlc",
|
|
67
|
+
"packageManager": "pnpm@11.7.0"
|
|
68
|
+
}
|