dsh-voice 0.3.6 → 0.3.7
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/CHANGELOG.md +9 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/docs/USAGE.en.md +7 -3
- package/docs/USAGE.md +7 -3
- package/lib/paths.d.ts +21 -2
- package/lib/paths.js +134 -12
- package/lib/tools.js +33 -34
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.3.7 (2026-10-20)
|
|
8
|
+
|
|
9
|
+
- 修复 voice_stt 的引擎/地址错配:openai、groq 分支不再写死厂商地址,一律使用配置 `asrBaseUrl`;模型传的 `engine` 与配置 `asrEngine` 不一致时直接报错,避免把 groq 的密钥发给 OpenAI 或把自建服务的密钥发给厂商
|
|
10
|
+
- 修复读写越界:`audio` 与 `output`/`outputDir` 解析后必须落在会话工作区内(含符号链接解析),工作区外的文件不再被整份上传给 ASR,也不再在工作区外创建文件
|
|
11
|
+
- 拒绝跟随符号链接写入(含悬空链接),新建文件用 `O_EXCL`;转写文本改为 0600
|
|
12
|
+
- `voice_preview` 复用 `resolveOutputPath`:`overwrite=false` 时同名样例加序号,不再直接覆盖
|
|
13
|
+
- 音频大小按实际读到的字节数复查,`stat` 报 0 字节的伪文件不再绕过 25MB 预检
|
|
14
|
+
- 新增 8 项回归测试(工作区越界、私钥不被上传、符号链接、engine 错配、自定义网关、preview 不覆盖)
|
|
15
|
+
|
|
7
16
|
## 0.3.6 (2026-10-05)
|
|
8
17
|
|
|
9
18
|
- ASR 网络与响应体读取均受超时和取消控制;保留用户取消原因,空白或缺失识别结果明确报错,不伪造成功文本文件。
|
package/README.en.md
CHANGED
|
@@ -8,6 +8,8 @@ Generate speech from text or transcribe audio through a compatible API.
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-voice) [](https://www.npmjs.com/package/dsh-voice)
|
|
10
10
|
|
|
11
|
+
Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-voice/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-voice/pulls).
|
|
12
|
+
|
|
11
13
|
## What it does
|
|
12
14
|
|
|
13
15
|
- Generate speech using the Edge online read-aloud service.
|
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-voice) [](https://www.npmjs.com/package/dsh-voice)
|
|
10
10
|
|
|
11
|
+
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-voice/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-voice/pulls)。
|
|
12
|
+
|
|
11
13
|
## 功能
|
|
12
14
|
|
|
13
15
|
- 使用 Edge 在线朗读服务生成语音。
|
package/docs/USAGE.en.md
CHANGED
|
@@ -40,7 +40,7 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
|
|
|
40
40
|
| Tool | Purpose | Key parameters |
|
|
41
41
|
| :-- | :-- | :-- |
|
|
42
42
|
| `voice_tts` | Synthesize MP3 from text (Edge online service) | `text` required; `voice`/`rate`/`pitch`/`output` optional |
|
|
43
|
-
| `voice_stt` | Transcribe audio to text | `audio` required; `
|
|
43
|
+
| `voice_stt` | Transcribe audio to text | `audio` required; `model`/`language`/`prompt`/`output` optional; `engine` may only repeat the configured `asrEngine` |
|
|
44
44
|
| `voice_list` | Curated voice catalog | none |
|
|
45
45
|
| `voice_preview` | Batch-generate short preview MP3s | optional `voices` (≤8) / `text` / `outputDir` |
|
|
46
46
|
| `voice_health` | Offline config self-check | none |
|
|
@@ -50,7 +50,7 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
|
|
|
50
50
|
```text
|
|
51
51
|
voice_tts { text: hello world } # outputs voice_output.mp3
|
|
52
52
|
voice_tts { text: hello, voice: en-US-AriaNeural } # English female voice
|
|
53
|
-
voice_stt { audio:
|
|
53
|
+
voice_stt { audio: meeting.mp3, language: zh } # transcribe a recording inside the session workspace
|
|
54
54
|
voice_list {}
|
|
55
55
|
voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # generate two preview samples
|
|
56
56
|
voice_health {} # self-check TTS / ASR / proxy config
|
|
@@ -60,7 +60,11 @@ voice_health {} # self-check TTS /
|
|
|
60
60
|
|
|
61
61
|
- **Direct edge-tts protocol**: the Sec-MS-GEC token is **generated locally** with the official DRM algorithm (SHA256 of Windows file time + trusted client token, 5-minute windows); transport uses the `ws` library with permessage-deflate and an optional HTTP CONNECT proxy tunnel
|
|
62
62
|
- **API configuration**: TTS needs no separate API key. STT uses your configured ASR service, whose pricing and limits apply.
|
|
63
|
-
- Up-front validation: text ≤ 5000 chars, audio ≤ 25MB; same-name outputs auto-suffixed
|
|
63
|
+
- Up-front validation: text ≤ 5000 chars, audio ≤ 25MB (re-checked against the bytes actually read); same-name outputs auto-suffixed
|
|
64
|
+
- **Reads and writes stay inside the session workspace**: `audio`, `output` and `outputDir` must resolve (symlinks included) under `session.header.cwd`; anything outside is rejected. Copy the file in first.
|
|
65
|
+
- **Symlinks are never written through**: a target that is a symlink (including a dangling one) is refused, and new files are created with `O_EXCL`.
|
|
66
|
+
- **Transcripts are 0600**: the `.txt` written by `voice_stt` is owner-only (Windows does not enforce the mode bits).
|
|
67
|
+
- **The key only goes to the configured endpoint**: ASR always uses `asrBaseUrl` from config (the groq/openai presets are defaults only); an `engine` argument that differs from config is rejected instead of shipping the key to another vendor.
|
|
64
68
|
- Protocol aligned with current open-source edge-tts (7.x) — no reliance on the outdated token endpoint
|
|
65
69
|
|
|
66
70
|
## Development
|
package/docs/USAGE.md
CHANGED
|
@@ -40,7 +40,7 @@ dsh plugin --profile web remove dsh-voice
|
|
|
40
40
|
| 工具 | 作用 | 关键参数 |
|
|
41
41
|
| :-- | :-- | :-- |
|
|
42
42
|
| `voice_tts` | 文字合成 MP3(Edge 在线服务) | `text` 必填;`voice`/`rate`/`pitch`/`output` 可选 |
|
|
43
|
-
| `voice_stt` | 音频转文字 | `audio` 必填;`
|
|
43
|
+
| `voice_stt` | 音频转文字 | `audio` 必填;`model`/`language`/`prompt`/`output` 可选;`engine` 只能与配置 `asrEngine` 相同 |
|
|
44
44
|
| `voice_list` | 常用音色清单 | 无 |
|
|
45
45
|
| `voice_preview` | 音色试听:批量生成短样例 MP3 | `voices`(≤8 个)/ `text` / `outputDir` 可选 |
|
|
46
46
|
| `voice_health` | 配置自检(不联网) | 无 |
|
|
@@ -50,7 +50,7 @@ dsh plugin --profile web remove dsh-voice
|
|
|
50
50
|
```text
|
|
51
51
|
voice_tts { text: 今天的 AI 早报来了 } # 晓晓女声,输出 voice_output.mp3
|
|
52
52
|
voice_tts { text: hello, voice: en-US-AriaNeural } # 英文女声
|
|
53
|
-
voice_stt { audio:
|
|
53
|
+
voice_stt { audio: meeting.mp3, language: zh } # 转写会话工作区里的录音
|
|
54
54
|
voice_list {}
|
|
55
55
|
voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # 生成两个试听样例
|
|
56
56
|
voice_health {} # 自检 TTS / ASR / 代理配置
|
|
@@ -60,7 +60,11 @@ voice_health {} # 自检 TTS / ASR /
|
|
|
60
60
|
|
|
61
61
|
- **edge-tts 协议直连**:Sec-MS-GEC 令牌按官方 DRM 算法**本地生成**(SHA256(Windows 文件时间 + TrustedClientToken),5 分钟窗口),WS 传输用 `ws` 库 + permessage-deflate 压缩 + 可选 HTTP CONNECT 代理隧道
|
|
62
62
|
- **接口配置**:TTS 无需另填 API Key;STT 使用你配置的 ASR 服务,其计费与额度由该服务决定。
|
|
63
|
-
- 文本 ≤5000 字符、音频 ≤25MB
|
|
63
|
+
- 文本 ≤5000 字符、音频 ≤25MB 前置校验(读入后再按实际字节数复查一次);输出同名自动加序号
|
|
64
|
+
- **读写都在会话工作区内**:`audio` 与 `output` / `outputDir` 解析后(含符号链接)必须落在 `session.header.cwd` 之下,越界直接报错。想转写工作区外的录音,先复制进来;输出同理
|
|
65
|
+
- **不跟随符号链接写文件**:目标是符号链接(含悬空链接)时拒绝写入,新建文件用 `O_EXCL`,避免检查完再被换成链接
|
|
66
|
+
- **转写文本 0600**:`voice_stt` 写出的 `.txt` 只给当前用户读写(Windows 不检查权限位)
|
|
67
|
+
- **密钥只发给配置的地址**:ASR 请求地址一律取配置 `asrBaseUrl`(groq/openai 的预设只是缺省值),模型传的 `engine` 与配置不一致会直接报错,避免把密钥发到另一家的主机
|
|
64
68
|
- Edge TTS 协议适配保留在插件实现中,不使用旧令牌端点。
|
|
65
69
|
|
|
66
70
|
## 开发
|
package/lib/paths.d.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* 校验音频文件:必须存在于会话工作区内,且是普通文件。
|
|
3
|
+
*
|
|
4
|
+
* 相对路径按 cwd(会话工作区)解析,返回解析后的绝对路径;符号链接会先
|
|
5
|
+
* 解析真实路径再判断边界,避免把工作区外的隐私文件(如 id_ed25519)当成
|
|
6
|
+
* 音频整份上传给 ASR 服务。
|
|
7
|
+
*/
|
|
2
8
|
export declare function assertAudioFile(input: string, cwd?: string): string;
|
|
3
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。
|
|
11
|
+
*
|
|
12
|
+
* 目标必须落在会话工作区内;已存在的符号链接直接拒绝,避免顺着悬空链接
|
|
13
|
+
* 在工作区外创建文件。overwrite=false 时跳过已存在的路径(含符号链接)。
|
|
14
|
+
*/
|
|
4
15
|
export declare function resolveOutputPath(explicit: string | undefined, defaultName: string, overwrite: boolean, cwd?: string): string;
|
|
16
|
+
/** 确保输出目录存在且落在工作区内:不存在则创建,越界或符号链接报错。 */
|
|
17
|
+
export declare function ensureWorkspaceDirectory(input: string, cwd?: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* 写工作区内的文件:先建父目录,再按「新建用 wx / 覆盖用 w」落盘。
|
|
20
|
+
*
|
|
21
|
+
* 新建时用 O_EXCL,避免检查完成到写入之间被人塞进一个符号链接。
|
|
22
|
+
*/
|
|
23
|
+
export declare function writeWorkspaceFile(target: string, data: string | Buffer, cwd?: string, mode?: number): void;
|
package/lib/paths.js
CHANGED
|
@@ -1,30 +1,152 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 输出路径、音频文件与工作区边界校验。
|
|
3
3
|
*
|
|
4
4
|
* @module dsh-voice/paths
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
8
|
-
/**
|
|
6
|
+
import { lstatSync, mkdirSync, openSync, closeSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
|
+
/** 会话工作区的真实路径(解析符号链接);取不到就退回解析后的绝对路径。 */
|
|
9
|
+
function workspaceRoot(cwd) {
|
|
10
|
+
const absolute = resolve(cwd);
|
|
11
|
+
try {
|
|
12
|
+
return realpathSync(absolute);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return absolute;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** candidate 是否等于 root 或位于 root 之下。 */
|
|
19
|
+
function isInside(root, candidate) {
|
|
20
|
+
const rel = relative(root, candidate);
|
|
21
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + sep));
|
|
22
|
+
}
|
|
23
|
+
function pathState(target) {
|
|
24
|
+
try {
|
|
25
|
+
return lstatSync(target).isSymbolicLink() ? 'symlink' : 'present';
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return 'missing';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 沿着最近的已存在祖先解析真实路径,再接回尚不存在的后缀。
|
|
33
|
+
*
|
|
34
|
+
* 用于输出路径:目标文件通常还不存在,但仍要判断它落盘时会不会被
|
|
35
|
+
* 中间某一层符号链接带到工作区之外。
|
|
36
|
+
*/
|
|
37
|
+
function resolveThroughExistingAncestor(target) {
|
|
38
|
+
let current = target;
|
|
39
|
+
const missing = [];
|
|
40
|
+
for (;;) {
|
|
41
|
+
try {
|
|
42
|
+
const real = realpathSync(current);
|
|
43
|
+
return missing.length === 0 ? real : join(real, ...missing.slice().reverse());
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
const parent = dirname(current);
|
|
47
|
+
if (parent === current)
|
|
48
|
+
return target;
|
|
49
|
+
missing.push(basename(current));
|
|
50
|
+
current = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** 输出路径必须落在工作区内,且不能是符号链接(含悬空链接)。 */
|
|
55
|
+
function assertWritableTarget(target, root, label) {
|
|
56
|
+
if (pathState(target) === 'symlink') {
|
|
57
|
+
throw new Error(label + ' 是符号链接,拒绝写入:' + target + '(请改用工作区内的普通文件路径)');
|
|
58
|
+
}
|
|
59
|
+
const real = resolveThroughExistingAncestor(target);
|
|
60
|
+
if (!isInside(root, real)) {
|
|
61
|
+
throw new Error(label + ' 超出会话工作区:' + target + '(实际解析到 ' + real + ')。请把输出放在会话工作目录内。');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 校验音频文件:必须存在于会话工作区内,且是普通文件。
|
|
66
|
+
*
|
|
67
|
+
* 相对路径按 cwd(会话工作区)解析,返回解析后的绝对路径;符号链接会先
|
|
68
|
+
* 解析真实路径再判断边界,避免把工作区外的隐私文件(如 id_ed25519)当成
|
|
69
|
+
* 音频整份上传给 ASR 服务。
|
|
70
|
+
*/
|
|
9
71
|
export function assertAudioFile(input, cwd = process.cwd()) {
|
|
10
|
-
const
|
|
11
|
-
|
|
72
|
+
const root = workspaceRoot(cwd);
|
|
73
|
+
const requested = resolve(root, input);
|
|
74
|
+
let real;
|
|
75
|
+
try {
|
|
76
|
+
real = realpathSync(requested);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
12
79
|
throw new Error('音频文件不存在:' + input);
|
|
13
80
|
}
|
|
14
|
-
|
|
81
|
+
if (!isInside(root, real)) {
|
|
82
|
+
throw new Error('音频文件必须位于会话工作区内:' + input + '(实际解析到 ' + real + ')。请先复制到工作目录再转写。');
|
|
83
|
+
}
|
|
84
|
+
if (!statSync(real).isFile())
|
|
85
|
+
throw new Error('音频文件不是普通文件:' + input);
|
|
86
|
+
return requested;
|
|
15
87
|
}
|
|
16
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。
|
|
90
|
+
*
|
|
91
|
+
* 目标必须落在会话工作区内;已存在的符号链接直接拒绝,避免顺着悬空链接
|
|
92
|
+
* 在工作区外创建文件。overwrite=false 时跳过已存在的路径(含符号链接)。
|
|
93
|
+
*/
|
|
17
94
|
export function resolveOutputPath(explicit, defaultName, overwrite, cwd = process.cwd()) {
|
|
18
|
-
|
|
19
|
-
|
|
95
|
+
const root = workspaceRoot(cwd);
|
|
96
|
+
const requested = explicit !== undefined && explicit.trim() !== '' ? explicit.trim() : defaultName;
|
|
97
|
+
const target = resolve(root, requested);
|
|
98
|
+
assertWritableTarget(target, root, '输出路径');
|
|
99
|
+
if (overwrite || pathState(target) === 'missing')
|
|
20
100
|
return target;
|
|
21
101
|
const directory = dirname(target);
|
|
22
102
|
const base = basename(target, extname(target));
|
|
23
103
|
const extension = extname(target);
|
|
24
104
|
for (let index = 1; index < 1000; index++) {
|
|
25
105
|
const candidate = join(directory, base + '_' + index + extension);
|
|
26
|
-
if (
|
|
27
|
-
|
|
106
|
+
if (pathState(candidate) !== 'missing')
|
|
107
|
+
continue;
|
|
108
|
+
assertWritableTarget(candidate, root, '输出路径');
|
|
109
|
+
return candidate;
|
|
28
110
|
}
|
|
29
111
|
throw new Error('找不到可用的输出文件名(同名文件超过 999 个),请显式指定 output。');
|
|
30
112
|
}
|
|
113
|
+
/** 确保输出目录存在且落在工作区内:不存在则创建,越界或符号链接报错。 */
|
|
114
|
+
export function ensureWorkspaceDirectory(input, cwd = process.cwd()) {
|
|
115
|
+
const root = workspaceRoot(cwd);
|
|
116
|
+
const target = resolve(root, input);
|
|
117
|
+
if (pathState(target) === 'symlink') {
|
|
118
|
+
throw new Error('输出目录是符号链接,拒绝写入:' + target);
|
|
119
|
+
}
|
|
120
|
+
const real = resolveThroughExistingAncestor(target);
|
|
121
|
+
if (!isInside(root, real)) {
|
|
122
|
+
throw new Error('输出目录超出会话工作区:' + target + '(实际解析到 ' + real + ')。请把输出放在会话工作目录内。');
|
|
123
|
+
}
|
|
124
|
+
mkdirSync(target, { recursive: true });
|
|
125
|
+
const after = realpathSync(target);
|
|
126
|
+
if (!isInside(root, after)) {
|
|
127
|
+
throw new Error('输出目录超出会话工作区:' + target + '(实际解析到 ' + after + ')');
|
|
128
|
+
}
|
|
129
|
+
return target;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 写工作区内的文件:先建父目录,再按「新建用 wx / 覆盖用 w」落盘。
|
|
133
|
+
*
|
|
134
|
+
* 新建时用 O_EXCL,避免检查完成到写入之间被人塞进一个符号链接。
|
|
135
|
+
*/
|
|
136
|
+
export function writeWorkspaceFile(target, data, cwd = process.cwd(), mode) {
|
|
137
|
+
const root = workspaceRoot(cwd);
|
|
138
|
+
assertWritableTarget(target, root, '输出路径');
|
|
139
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
140
|
+
assertWritableTarget(target, root, '输出路径');
|
|
141
|
+
if (pathState(target) === 'missing') {
|
|
142
|
+
const fd = openSync(target, 'wx', mode);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(fd, data);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
writeFileSync(target, data, mode === undefined ? undefined : { mode });
|
|
152
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module dsh-voice/tools
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import { basename,
|
|
6
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
8
|
import { synthesizeSpeech } from './edge-tts.js';
|
|
9
|
-
import { assertAudioFile, resolveOutputPath } from './paths.js';
|
|
9
|
+
import { assertAudioFile, ensureWorkspaceDirectory, resolveOutputPath, writeWorkspaceFile } from './paths.js';
|
|
10
10
|
import { createProxyFetch } from './proxy-fetch.js';
|
|
11
11
|
import { assertAudioSize, transcribe } from './stt.js';
|
|
12
12
|
import { isValidVoiceId, VOICES } from './voices.js';
|
|
@@ -87,11 +87,6 @@ function sessionCwd(exec) {
|
|
|
87
87
|
const cwd = exec?.agent?.session?.header?.cwd;
|
|
88
88
|
return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : process.cwd();
|
|
89
89
|
}
|
|
90
|
-
/** 写文件前先建父目录,避免合成/转写完成后才因 ENOENT 白烧一次接口调用。 */
|
|
91
|
-
function writeFileWithDir(target, data) {
|
|
92
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
93
|
-
writeFileSync(target, data);
|
|
94
|
-
}
|
|
95
90
|
const voiceItemSchema = {
|
|
96
91
|
type: 'object',
|
|
97
92
|
properties: { id: { type: 'string' }, gender: { type: 'string' }, locale: { type: 'string' } },
|
|
@@ -135,6 +130,8 @@ const previewSchema = {
|
|
|
135
130
|
},
|
|
136
131
|
additionalProperties: true,
|
|
137
132
|
};
|
|
133
|
+
/** 自定义端点没配 asrModel 时的兜底模型名。 */
|
|
134
|
+
const FALLBACK_ASR_MODEL = 'whisper-1';
|
|
138
135
|
/** 默认试听文本(中英混合,便于感知发音差异)。 */
|
|
139
136
|
export const DEFAULT_PREVIEW_TEXT = '你好,这是音色试听。Hello, this is a voice preview.';
|
|
140
137
|
/** voice_preview 同时合成的音色数上限:有界并发,避免 8 个音色串行把最坏等待拖成 8 倍超时。 */
|
|
@@ -203,7 +200,7 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
203
200
|
const output = resolveOutputPath(optionalString(args, 'output'), 'voice_output.mp3', cfg.overwrite, sessionCwd(exec));
|
|
204
201
|
const audio = await raceAbort((deps.tts ?? synthesizeSpeech)({ text, voice, rate, pitch }, { proxyUrl: cfg.proxyUrl }, timeout, signal), signal);
|
|
205
202
|
throwIfAborted(signal);
|
|
206
|
-
|
|
203
|
+
writeWorkspaceFile(output, audio, sessionCwd(exec));
|
|
207
204
|
return { output, bytes: audio.length, voice, rate, pitch, textLength: text.length };
|
|
208
205
|
},
|
|
209
206
|
timeoutMs: timeout + 10000,
|
|
@@ -213,7 +210,7 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
213
210
|
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。',
|
|
214
211
|
parameters: compileParameters({
|
|
215
212
|
audio: { type: 'string', required: true, description: '音频文件路径(必填)。' },
|
|
216
|
-
engine: { type: 'string', enum: ['groq', 'openai', 'custom'], description: '
|
|
213
|
+
engine: { type: 'string', enum: ['groq', 'openai', 'custom'], description: '引擎(可选):只接受与配置 asrEngine 相同的值,用于显式确认;接口地址与密钥始终取配置。' },
|
|
217
214
|
model: { type: 'string', description: '模型名(可选,覆盖配置)。' },
|
|
218
215
|
language: { type: 'string', description: '语言提示,如 zh(可选)。' },
|
|
219
216
|
prompt: { type: 'string', description: '提示词(专有名词/术语纠偏,可选)。' },
|
|
@@ -230,27 +227,26 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
230
227
|
const signal = execSignal(exec);
|
|
231
228
|
throwIfAborted(signal);
|
|
232
229
|
const args = asRecord(rawArgs);
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (engine === 'openai') {
|
|
239
|
-
baseUrl = 'https://api.openai.com/v1';
|
|
240
|
-
model = optionalString(args, 'model') ?? 'whisper-1';
|
|
230
|
+
const cwd = sessionCwd(exec);
|
|
231
|
+
const audioPath = assertAudioFile(requiredString(args, 'audio', '音频文件'), cwd);
|
|
232
|
+
const requestedEngine = optionalString(args, 'engine');
|
|
233
|
+
if (requestedEngine !== undefined && !['groq', 'openai', 'custom'].includes(requestedEngine)) {
|
|
234
|
+
throw new Error('engine 必须是 groq / openai / custom 之一(当前:' + requestedEngine + ')。');
|
|
241
235
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
model = optionalString(args, 'model') ?? cfg.asrModel;
|
|
236
|
+
if (requestedEngine !== undefined && requestedEngine !== cfg.asrEngine) {
|
|
237
|
+
throw new Error('voice_stt 不支持用 engine 参数临时换引擎(配置 asrEngine=' + cfg.asrEngine + ',本次传入 ' + requestedEngine + ')。接口地址与密钥是一组配置,混着用会把密钥发到非预期的服务;如需切换请改配置里的 asrEngine / asrBaseUrl。');
|
|
245
238
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
239
|
+
// 接口地址一律取配置:openai/groq 预设已由 resolveConfig 回填,自定义端点原样使用。
|
|
240
|
+
const baseUrl = cfg.asrBaseUrl;
|
|
241
|
+
if (baseUrl === '')
|
|
242
|
+
throw new Error('未配置 ASR 接口地址:asrEngine=custom 时必须在配置里提供 asrBaseUrl。');
|
|
243
|
+
assertAudioSize(statSync(audioPath).size);
|
|
244
|
+
const audio = readFileSync(audioPath);
|
|
245
|
+
// 伪文件(如 procfs)stat 报 0 字节,按实际读到的长度再判一次。
|
|
246
|
+
assertAudioSize(audio.length);
|
|
247
|
+
const model = optionalString(args, 'model') ?? (cfg.asrModel !== '' ? cfg.asrModel : FALLBACK_ASR_MODEL);
|
|
252
248
|
const { text } = await raceAbort((deps.stt ?? transcribe)(baseUrl, cfg.asrApiKey, {
|
|
253
|
-
audio
|
|
249
|
+
audio,
|
|
254
250
|
filename: basename(audioPath),
|
|
255
251
|
model,
|
|
256
252
|
language: optionalString(args, 'language'),
|
|
@@ -260,8 +256,9 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
260
256
|
let transcriptFile = '';
|
|
261
257
|
const output = optionalString(args, 'output');
|
|
262
258
|
if (output !== undefined) {
|
|
263
|
-
const target = resolveOutputPath(output, '', cfg.overwrite,
|
|
264
|
-
|
|
259
|
+
const target = resolveOutputPath(output, '', cfg.overwrite, cwd);
|
|
260
|
+
// 转写文本可能含隐私内容,只用 0600 写入。
|
|
261
|
+
writeWorkspaceFile(target, text, cwd, 0o600);
|
|
265
262
|
transcriptFile = target;
|
|
266
263
|
}
|
|
267
264
|
// The output schema declares these as strings — never return null, or
|
|
@@ -309,8 +306,8 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
309
306
|
const text = optionalString(args, 'text') ?? DEFAULT_PREVIEW_TEXT;
|
|
310
307
|
if (text.length > 200)
|
|
311
308
|
throw new Error('试听文本请控制在 200 字以内(试听要短平快)。');
|
|
312
|
-
const
|
|
313
|
-
|
|
309
|
+
const cwd = sessionCwd(exec);
|
|
310
|
+
const outDir = ensureWorkspaceDirectory(optionalString(args, 'outputDir') ?? 'voice_previews', cwd);
|
|
314
311
|
const outcomes = await mapLimit(targets, PREVIEW_CONCURRENCY, signal, async (voice) => {
|
|
315
312
|
throwIfAborted(signal);
|
|
316
313
|
if (!isValidVoiceId(voice)) {
|
|
@@ -319,8 +316,10 @@ export function buildVoiceTools(config, deps = {}) {
|
|
|
319
316
|
try {
|
|
320
317
|
const audio = await raceAbort((deps.tts ?? synthesizeSpeech)({ text, voice, rate: cfg.ttsRate, pitch: cfg.ttsPitch }, { proxyUrl: cfg.proxyUrl }, timeout, signal), signal);
|
|
321
318
|
throwIfAborted(signal);
|
|
322
|
-
const
|
|
323
|
-
|
|
319
|
+
const name = 'voice-preview-' + voice.replace(/[^a-zA-Z0-9-]/g, '_') + '.mp3';
|
|
320
|
+
// 复用 resolveOutputPath:overwrite=false 时改名加序号,不再直接覆盖同名样例。
|
|
321
|
+
const file = resolveOutputPath(join(outDir, name), '', cfg.overwrite, cwd);
|
|
322
|
+
writeWorkspaceFile(file, audio, cwd);
|
|
324
323
|
return { voice, output: file, bytes: audio.length };
|
|
325
324
|
}
|
|
326
325
|
catch (error) {
|