dsh-voice 0.3.5 → 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 +30 -0
- package/README.en.md +25 -70
- package/README.md +23 -71
- package/docs/CHANGELOG.en.md +13 -0
- package/docs/USAGE.en.md +80 -0
- package/docs/USAGE.md +80 -0
- package/docs/VALIDATION.md +11 -0
- package/lib/paths.d.ts +21 -2
- package/lib/paths.js +134 -12
- package/lib/stt.js +23 -4
- package/lib/tools.js +33 -34
- package/package.json +5 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 更新记录
|
|
2
|
+
|
|
3
|
+
[返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
|
|
4
|
+
|
|
5
|
+
[历史英文记录](docs/CHANGELOG.en.md)
|
|
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
|
+
|
|
16
|
+
## 0.3.6 (2026-10-05)
|
|
17
|
+
|
|
18
|
+
- ASR 网络与响应体读取均受超时和取消控制;保留用户取消原因,空白或缺失识别结果明确报错,不伪造成功文本文件。
|
|
19
|
+
|
|
20
|
+
## 0.3.5 (2026-09-28)
|
|
21
|
+
|
|
22
|
+
- 更新官方 Harness 0.2.0-rc.1 的兼容声明和共同加载验证;运行时代码未变。验证范围见[验证记录](docs/VALIDATION.md)。
|
|
23
|
+
|
|
24
|
+
## 0.3.4 (2026-09-19)
|
|
25
|
+
|
|
26
|
+
- 修复代理路径 STT 上传 `[object FormData]`、25MB 校验在读入之后、输出目录不存在白烧一次合成、默认输出落宿主 cwd;`exec.signal` 全程透传、preview 改 3 路并发。测试 55 项。
|
|
27
|
+
|
|
28
|
+
## 更早的改动
|
|
29
|
+
|
|
30
|
+
完整历史可查阅 [GitHub 提交记录](https://github.com/STARDUSTLC666/dsh-voice/commits/master)。
|
package/README.en.md
CHANGED
|
@@ -1,93 +1,48 @@
|
|
|
1
|
+
# dsh-voice
|
|
2
|
+
|
|
1
3
|
[中文](README.md)
|
|
2
4
|
|
|
3
|
-

|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Generate speech from text or transcribe audio through a compatible API.
|
|
6
8
|
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-voice) [](https://www.npmjs.com/package/dsh-voice)
|
|
8
10
|
|
|
9
|
-
|
|
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).
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
- **voice_stt**: speech-to-text over any **OpenAI-compatible ASR endpoint** (Groq / OpenAI / custom)
|
|
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
|
|
13
|
+
## What it does
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
- Generate speech using the Edge online read-aloud service.
|
|
16
|
+
- List voices and generate batch voice previews.
|
|
17
|
+
- Transcribe audio using an OpenAI-compatible ASR endpoint.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
## Install
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
In DSH Desktop, install `dsh-voice` from the Plugins panel. If the bundled dsh command is available:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
dsh plugin --profile
|
|
24
|
+
dsh plugin --profile desktop add dsh-voice
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
dsh plugin --profile web remove dsh-voice
|
|
31
|
-
```
|
|
27
|
+
For the web version, replace `desktop` with `web`. Restart DSH after installation.
|
|
32
28
|
|
|
33
|
-
|
|
29
|
+
## Start using it
|
|
34
30
|
|
|
31
|
+
Ask to turn text into a Chinese MP3 and preview two voices. After configuring ASR, you can also request audio transcription.
|
|
35
32
|
|
|
36
|
-
##
|
|
33
|
+
## Requirements and configuration
|
|
37
34
|
|
|
38
|
-
|
|
35
|
+
Speech synthesis requires network access. Transcription needs an ASR endpoint and key. A plugin-specific proxy can be configured.
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
- id: voice
|
|
42
|
-
name: 'dsh-voice'
|
|
43
|
-
config:
|
|
44
|
-
asrEngine: groq # groq | openai | custom
|
|
45
|
-
asrModel: whisper-large-v3-turbo # Groq whisper model
|
|
46
|
-
# asrApiKey: gsk_... # prefer env var DSH_VOICE_ASR_KEY
|
|
47
|
-
ttsVoice: zh-CN-XiaoxiaoNeural # default voice
|
|
48
|
-
# proxyUrl: http://127.0.0.1:7890 # enable when the ASR endpoint needs a special proxy
|
|
49
|
-
```
|
|
37
|
+
Detailed configuration, tool arguments and troubleshooting are in the [usage guide](docs/USAGE.en.md). For standalone development, follow the Node requirement in [package.json](package.json).
|
|
50
38
|
|
|
51
|
-
##
|
|
52
|
-
|
|
53
|
-
| Tool | Purpose | Key parameters |
|
|
54
|
-
| :-- | :-- | :-- |
|
|
55
|
-
| `voice_tts` | Synthesize MP3 from text (free) | `text` required; `voice`/`rate`/`pitch`/`output` optional |
|
|
56
|
-
| `voice_stt` | Transcribe audio to text | `audio` required; `engine`/`model`/`language`/`prompt`/`output` optional |
|
|
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 |
|
|
60
|
-
|
|
61
|
-
### Examples
|
|
62
|
-
|
|
63
|
-
```text
|
|
64
|
-
voice_tts { text: hello world } # outputs voice_output.mp3
|
|
65
|
-
voice_tts { text: hello, voice: en-US-AriaNeural } # English female voice
|
|
66
|
-
voice_stt { audio: E:\audio\meeting.mp3, language: zh } # transcribe a recording
|
|
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
|
|
70
|
-
```
|
|
39
|
+
## Documentation
|
|
71
40
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
- Up-front validation: text ≤ 5000 chars, audio ≤ 25MB; same-name outputs auto-suffixed
|
|
77
|
-
- Protocol aligned with current open-source edge-tts (7.x) — no reliance on the outdated token endpoint
|
|
78
|
-
|
|
79
|
-
## Development
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
pnpm install
|
|
83
|
-
pnpm test # build + offline unit tests with mocked TTS/ASR
|
|
84
|
-
pnpm test:integration # opt-in real edge-tts request; network and assertion errors fail
|
|
85
|
-
```
|
|
41
|
+
- [Usage and troubleshooting](docs/USAGE.en.md)
|
|
42
|
+
- [Changelog](CHANGELOG.md)
|
|
43
|
+
- [Validation scope and history](docs/VALIDATION.md)
|
|
44
|
+
- [Report a problem or suggest a feature](https://github.com/STARDUSTLC666/dsh-voice/issues)
|
|
86
45
|
|
|
87
46
|
## License
|
|
88
47
|
|
|
89
|
-
MIT
|
|
90
|
-
|
|
91
|
-
## Changelog
|
|
92
|
-
|
|
93
|
-
- **0.3.4 (2026-09-18)**: 修复代理路径 STT 上传 `[object FormData]`、25MB 校验在读入之后、输出目录不存在白烧一次合成、默认输出落宿主 cwd; `exec.signal` 全程透传、preview 改 3 路并发. 测试 55 项.
|
|
48
|
+
[MIT](LICENSE)
|
package/README.md
CHANGED
|
@@ -1,96 +1,48 @@
|
|
|
1
|
-
[English](README.en.md)
|
|
2
|
-
|
|
3
1
|
# dsh-voice
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
   
|
|
3
|
+
[English](README.en.md)
|
|
8
4
|
|
|
9
|
-
|
|
5
|
+

|
|
10
6
|
|
|
7
|
+
把文字生成语音,或通过兼容接口把音频转为文字。
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-voice) [](https://www.npmjs.com/package/dsh-voice)
|
|
13
10
|
|
|
14
|
-
-
|
|
15
|
-
- **voice_stt**:语音转文字,走 **OpenAI 兼容 ASR 接口**(Groq / OpenAI / 自定义端点)
|
|
16
|
-
- **voice_list**:音色清单
|
|
17
|
-
- **voice_preview**:音色试听,用一段样例文本批量生成短 MP3,挑音色不用盲选
|
|
18
|
-
- **voice_health**:配置自检,体检 TTS 音色 / ASR 密钥 / 接口地址 / 代理(不联网)
|
|
11
|
+
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-voice/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-voice/pulls)。
|
|
19
12
|
|
|
20
|
-
##
|
|
13
|
+
## 功能
|
|
21
14
|
|
|
22
|
-
|
|
15
|
+
- 使用 Edge 在线朗读服务生成语音。
|
|
16
|
+
- 列出音色并批量试听。
|
|
17
|
+
- 通过 OpenAI 兼容 ASR 接口转写音频。
|
|
23
18
|
|
|
24
19
|
## 安装
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
dsh plugin --profile web add dsh-voice
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## 卸载
|
|
21
|
+
桌面版可在「插件」面板按包名 `dsh-voice` 安装。已配置 dsh 命令时也可使用:
|
|
31
22
|
|
|
32
23
|
```bash
|
|
33
|
-
dsh plugin --profile
|
|
24
|
+
dsh plugin --profile desktop add dsh-voice
|
|
34
25
|
```
|
|
35
26
|
|
|
36
|
-
|
|
27
|
+
网页版把命令中的 `desktop` 改为 `web`。安装后重启 DSH。
|
|
37
28
|
|
|
29
|
+
## 开始使用
|
|
38
30
|
|
|
39
|
-
|
|
31
|
+
可说:“把这段文字生成中文 MP3,并给我两个音色试听。”配置 ASR 后,也可要求转写音频文件。
|
|
40
32
|
|
|
41
|
-
|
|
33
|
+
## 依赖与配置
|
|
42
34
|
|
|
43
|
-
|
|
44
|
-
- id: voice
|
|
45
|
-
name: 'dsh-voice'
|
|
46
|
-
config:
|
|
47
|
-
asrEngine: groq # groq | openai | custom
|
|
48
|
-
asrModel: whisper-large-v3-turbo # groq 的 whisper 模型
|
|
49
|
-
# asrApiKey: gsk_... # 推荐改用环境变量 DSH_VOICE_ASR_KEY
|
|
50
|
-
ttsVoice: zh-CN-XiaoxiaoNeural # 默认音色
|
|
51
|
-
# proxyUrl: http://127.0.0.1:7890 # ASR 接口需要特殊代理时启用
|
|
52
|
-
```
|
|
35
|
+
语音合成需要网络连接;转写需配置对应 ASR 服务与密钥。代理可以单独设置。
|
|
53
36
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
| 工具 | 作用 | 关键参数 |
|
|
57
|
-
| :-- | :-- | :-- |
|
|
58
|
-
| `voice_tts` | 文字合成 MP3(免费) | `text` 必填;`voice`/`rate`/`pitch`/`output` 可选 |
|
|
59
|
-
| `voice_stt` | 音频转文字 | `audio` 必填;`engine`/`model`/`language`/`prompt`/`output` 可选 |
|
|
60
|
-
| `voice_list` | 常用音色清单 | 无 |
|
|
61
|
-
| `voice_preview` | 音色试听:批量生成短样例 MP3 | `voices`(≤8 个)/ `text` / `outputDir` 可选 |
|
|
62
|
-
| `voice_health` | 配置自检(不联网) | 无 |
|
|
63
|
-
|
|
64
|
-
### 示例
|
|
65
|
-
|
|
66
|
-
```text
|
|
67
|
-
voice_tts { text: 今天的 AI 早报来了 } # 晓晓女声,输出 voice_output.mp3
|
|
68
|
-
voice_tts { text: hello, voice: en-US-AriaNeural } # 英文女声
|
|
69
|
-
voice_stt { audio: E:\audio\meeting.mp3, language: zh } # 转写会议录音
|
|
70
|
-
voice_list {}
|
|
71
|
-
voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # 生成两个试听样例
|
|
72
|
-
voice_health {} # 自检 TTS / ASR / 代理配置
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
## 硬核细节
|
|
37
|
+
详细配置、工具参数与排错见[使用说明](docs/USAGE.md)。从源码独立开发时,Node 要求以 [package.json](package.json) 为准。
|
|
76
38
|
|
|
77
|
-
|
|
78
|
-
- **零 API 成本**:TTS 完全免费;STT 只花你选的 ASR 接口的钱
|
|
79
|
-
- 文本 ≤5000 字符、音频 ≤25MB 前置校验;输出同名自动加序号
|
|
80
|
-
- 协议对齐开源 edge-tts 当前版本(7.x),不依赖过时的令牌端点
|
|
39
|
+
## 文档
|
|
81
40
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
pnpm test # 构建 + 离线单元测试(使用模拟的 TTS/ASR)
|
|
87
|
-
pnpm test:integration # 显式联网,调用真实 edge-tts;网络或断言失败均报错
|
|
88
|
-
```
|
|
41
|
+
- [使用与排错](docs/USAGE.md)
|
|
42
|
+
- [更新记录](CHANGELOG.md)
|
|
43
|
+
- [验证范围与历史记录](docs/VALIDATION.md)
|
|
44
|
+
- [问题反馈与功能建议](https://github.com/STARDUSTLC666/dsh-voice/issues)
|
|
89
45
|
|
|
90
46
|
## License
|
|
91
47
|
|
|
92
|
-
MIT
|
|
93
|
-
|
|
94
|
-
## 版本记录
|
|
95
|
-
|
|
96
|
-
- **0.3.4(2026-09-18)**:修复代理路径 STT 上传 `[object FormData]`、25MB 校验在读入之后、输出目录不存在白烧一次合成、默认输出落宿主 cwd;`exec.signal` 全程透传、preview 改 3 路并发。测试 55 项。
|
|
48
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Historical release notes
|
|
2
|
+
|
|
3
|
+
[Current changelog](../CHANGELOG.md) · [Overview](../README.en.md)
|
|
4
|
+
|
|
5
|
+
These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
|
|
6
|
+
|
|
7
|
+
## 0.3.6 (2026-10-05)
|
|
8
|
+
|
|
9
|
+
- Apply cancellation and timeouts to both ASR transport and response parsing. Preserve cancellation reasons and reject empty or missing transcripts.
|
|
10
|
+
|
|
11
|
+
## 0.3.4 (2026-09-19)
|
|
12
|
+
|
|
13
|
+
- 修复代理路径 STT 上传 `[object FormData]`、25MB 校验在读入之后、输出目录不存在白烧一次合成、默认输出落宿主 cwd; `exec.signal` 全程透传、preview 改 3 路并发. 测试 55 项.
|
package/docs/USAGE.en.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# dsh-voice usage guide
|
|
2
|
+
|
|
3
|
+
[Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
|
|
4
|
+
|
|
5
|
+
## Current improvements
|
|
6
|
+
|
|
7
|
+
Check the ASR endpoint, key and response format after a failure. Blank results are not saved as successful transcripts. Verify TTS and ASR service availability separately.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
dsh plugin --profile web add dsh-voice
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Uninstall
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
dsh plugin --profile web remove dsh-voice
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
`voice_tts` works with zero config; `voice_stt` needs an ASR key:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
- id: voice
|
|
29
|
+
name: 'dsh-voice'
|
|
30
|
+
config:
|
|
31
|
+
asrEngine: groq # groq | openai | custom
|
|
32
|
+
asrModel: whisper-large-v3-turbo # Groq whisper model
|
|
33
|
+
# asrApiKey: gsk_... # prefer env var DSH_VOICE_ASR_KEY
|
|
34
|
+
ttsVoice: zh-CN-XiaoxiaoNeural # default voice
|
|
35
|
+
# proxyUrl: http://127.0.0.1:7890 # enable when the ASR endpoint needs a special proxy
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
| Tool | Purpose | Key parameters |
|
|
41
|
+
| :-- | :-- | :-- |
|
|
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; `model`/`language`/`prompt`/`output` optional; `engine` may only repeat the configured `asrEngine` |
|
|
44
|
+
| `voice_list` | Curated voice catalog | none |
|
|
45
|
+
| `voice_preview` | Batch-generate short preview MP3s | optional `voices` (≤8) / `text` / `outputDir` |
|
|
46
|
+
| `voice_health` | Offline config self-check | none |
|
|
47
|
+
|
|
48
|
+
### Examples
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
voice_tts { text: hello world } # outputs voice_output.mp3
|
|
52
|
+
voice_tts { text: hello, voice: en-US-AriaNeural } # English female voice
|
|
53
|
+
voice_stt { audio: meeting.mp3, language: zh } # transcribe a recording inside the session workspace
|
|
54
|
+
voice_list {}
|
|
55
|
+
voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # generate two preview samples
|
|
56
|
+
voice_health {} # self-check TTS / ASR / proxy config
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Implementation and file limits
|
|
60
|
+
|
|
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
|
+
- **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 (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.
|
|
68
|
+
- Protocol aligned with current open-source edge-tts (7.x) — no reliance on the outdated token endpoint
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pnpm install
|
|
74
|
+
pnpm test # build + offline unit tests with mocked TTS/ASR
|
|
75
|
+
pnpm test:integration # opt-in real edge-tts request; network and assertion errors fail
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/docs/USAGE.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# dsh-voice 使用说明
|
|
2
|
+
|
|
3
|
+
[返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
|
|
4
|
+
|
|
5
|
+
## 本次改进
|
|
6
|
+
|
|
7
|
+
转写失败时检查 ASR 地址、密钥与响应格式;空白识别结果不会作为成功转写保存。TTS 与 ASR 的外部服务可用性应分别核对。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
dsh plugin --profile web add dsh-voice
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 卸载
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
dsh plugin --profile web remove dsh-voice
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
`voice_tts` 零配置可用;`voice_stt` 需要 ASR 密钥:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
- id: voice
|
|
29
|
+
name: 'dsh-voice'
|
|
30
|
+
config:
|
|
31
|
+
asrEngine: groq # groq | openai | custom
|
|
32
|
+
asrModel: whisper-large-v3-turbo # groq 的 whisper 模型
|
|
33
|
+
# asrApiKey: gsk_... # 推荐改用环境变量 DSH_VOICE_ASR_KEY
|
|
34
|
+
ttsVoice: zh-CN-XiaoxiaoNeural # 默认音色
|
|
35
|
+
# proxyUrl: http://127.0.0.1:7890 # ASR 接口需要特殊代理时启用
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 工具一览
|
|
39
|
+
|
|
40
|
+
| 工具 | 作用 | 关键参数 |
|
|
41
|
+
| :-- | :-- | :-- |
|
|
42
|
+
| `voice_tts` | 文字合成 MP3(Edge 在线服务) | `text` 必填;`voice`/`rate`/`pitch`/`output` 可选 |
|
|
43
|
+
| `voice_stt` | 音频转文字 | `audio` 必填;`model`/`language`/`prompt`/`output` 可选;`engine` 只能与配置 `asrEngine` 相同 |
|
|
44
|
+
| `voice_list` | 常用音色清单 | 无 |
|
|
45
|
+
| `voice_preview` | 音色试听:批量生成短样例 MP3 | `voices`(≤8 个)/ `text` / `outputDir` 可选 |
|
|
46
|
+
| `voice_health` | 配置自检(不联网) | 无 |
|
|
47
|
+
|
|
48
|
+
### 示例
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
voice_tts { text: 今天的 AI 早报来了 } # 晓晓女声,输出 voice_output.mp3
|
|
52
|
+
voice_tts { text: hello, voice: en-US-AriaNeural } # 英文女声
|
|
53
|
+
voice_stt { audio: meeting.mp3, language: zh } # 转写会话工作区里的录音
|
|
54
|
+
voice_list {}
|
|
55
|
+
voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # 生成两个试听样例
|
|
56
|
+
voice_health {} # 自检 TTS / ASR / 代理配置
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 实现与文件限制
|
|
60
|
+
|
|
61
|
+
- **edge-tts 协议直连**:Sec-MS-GEC 令牌按官方 DRM 算法**本地生成**(SHA256(Windows 文件时间 + TrustedClientToken),5 分钟窗口),WS 传输用 `ws` 库 + permessage-deflate 压缩 + 可选 HTTP CONNECT 代理隧道
|
|
62
|
+
- **接口配置**:TTS 无需另填 API Key;STT 使用你配置的 ASR 服务,其计费与额度由该服务决定。
|
|
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` 与配置不一致会直接报错,避免把密钥发到另一家的主机
|
|
68
|
+
- Edge TTS 协议适配保留在插件实现中,不使用旧令牌端点。
|
|
69
|
+
|
|
70
|
+
## 开发
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pnpm install
|
|
74
|
+
pnpm test # 构建 + 离线单元测试(使用模拟的 TTS/ASR)
|
|
75
|
+
pnpm test:integration # 显式联网,调用真实 edge-tts;网络或断言失败均报错
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-voice 验证记录
|
|
2
|
+
|
|
3
|
+
本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
|
|
4
|
+
|
|
5
|
+
## 原中文记录
|
|
6
|
+
|
|
7
|
+
验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。55 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 5 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
|
|
8
|
+
|
|
9
|
+
## Original English record
|
|
10
|
+
|
|
11
|
+
Validation host: Harness `0.2.0-rc.1` built from official sources (commit `407e65c8`) with Node `24.16.0` on 2026-09-28. All 55 plugin tests pass in an isolated environment; all 18 plugins mount together in one host registering 5 tools, with tool schemas and health-check contracts passing. No live ports or external services were exercised in this round.
|
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/stt.js
CHANGED
|
@@ -59,24 +59,43 @@ export async function transcribe(baseUrl, apiKey, options, fetchImpl = globalThi
|
|
|
59
59
|
headers: { authorization: 'Bearer ' + apiKey },
|
|
60
60
|
body: form,
|
|
61
61
|
signal: requestSignal,
|
|
62
|
-
}),
|
|
62
|
+
}), requestSignal);
|
|
63
63
|
}
|
|
64
64
|
catch (error) {
|
|
65
65
|
throwIfAborted(abortSignal);
|
|
66
|
+
if (timeoutSignal.aborted)
|
|
67
|
+
throw new Error('ASR 请求超时,请重试或缩短音频。');
|
|
66
68
|
throw new Error('ASR 请求失败:' + (error instanceof Error ? error.message : String(error)) + '。若接口需要特殊代理(梯子),请在 cordis.patch.yml 配置 proxyUrl 后重启。');
|
|
67
69
|
}
|
|
68
70
|
if (!response.ok) {
|
|
69
|
-
|
|
71
|
+
let body;
|
|
72
|
+
try {
|
|
73
|
+
body = (await raceAbort(response.text(), requestSignal)).slice(0, 300);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
throwIfAborted(abortSignal);
|
|
77
|
+
if (timeoutSignal.aborted)
|
|
78
|
+
throw new Error('ASR 响应读取超时,请重试或缩短音频。');
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
70
81
|
throw new Error('ASR 失败:HTTP ' + response.status + '。' + body);
|
|
71
82
|
}
|
|
72
83
|
let json;
|
|
73
84
|
try {
|
|
74
|
-
json = await response.json();
|
|
85
|
+
json = await raceAbort(response.json(), requestSignal);
|
|
75
86
|
}
|
|
76
87
|
catch {
|
|
88
|
+
throwIfAborted(abortSignal);
|
|
89
|
+
if (timeoutSignal.aborted)
|
|
90
|
+
throw new Error('ASR 响应读取超时,请重试或缩短音频。');
|
|
77
91
|
throw new Error('ASR 响应不是合法 JSON。');
|
|
78
92
|
}
|
|
79
|
-
|
|
93
|
+
if (typeof json !== 'object' || json === null || typeof json.text !== 'string') {
|
|
94
|
+
throw new Error('ASR 响应缺少文本字段,请检查接口是否兼容 /audio/transcriptions。');
|
|
95
|
+
}
|
|
96
|
+
const text = json.text;
|
|
97
|
+
if (text.trim() === '')
|
|
98
|
+
throw new Error('未识别到语音,请检查音频是否包含清晰的人声。');
|
|
80
99
|
return { text, model: options.model };
|
|
81
100
|
}
|
|
82
101
|
/** 常见音频扩展名 → MIME。 */
|
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) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-voice",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.7",
|
|
4
|
+
"description": "把文字生成语音,或通过兼容接口把音频转为文字。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"lib",
|
|
18
18
|
"cordis.patch.yml",
|
|
19
19
|
"README.md",
|
|
20
|
-
"README.en.md"
|
|
20
|
+
"README.en.md",
|
|
21
|
+
"docs",
|
|
22
|
+
"CHANGELOG.md"
|
|
21
23
|
],
|
|
22
24
|
"scripts": {
|
|
23
25
|
"build": "tsc -p tsconfig.json",
|