dsh-voice-say 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -3
- package/client.js +134 -9
- package/index.js +194 -6
- package/lib/breeze.mjs +6 -0
- package/lib/director.mjs +56 -5
- package/lib/dshsession.mjs +290 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -96,6 +96,64 @@ voice_direct { text: "你不要再这样了,我真的会难过的。", styles:
|
|
|
96
96
|
→ 情绪 哽咽压抑
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## 导演台开在 DeepSeek 窗口里(0.6.0)
|
|
100
|
+
|
|
101
|
+
导演不必自己拿 key 直连模型。默认走 **DSH 里的专用会话**:插件会在 DeepSeek 窗口里建一个标题叫
|
|
102
|
+
`读音导演 · dsh-voice-say` 的会话,之后每次「这句怎么念」都在那个窗口里问 —— 你能在 GUI 里看见它想了什么、
|
|
103
|
+
为什么这么断句,也能随时进去插一句改方向。
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
设置页点「试算标点」
|
|
107
|
+
→ 插件找到 DSH(桥接里记着的 baseUrl)→ 用本机会话密钥签一个 Cookie
|
|
108
|
+
→ session/prompt:把「规则 + 人设 + 七情 + 最近聊天 + 这句台词」发进那个会话
|
|
109
|
+
→ session/page 轮询,等这一轮说完 → 拿回 JSON(render / notes / emotion)
|
|
110
|
+
→ 过四道保底(逐字核对 → 重试 → 盖章法 → 退回原文)→ 交给引擎念
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
为什么这样更省心:
|
|
114
|
+
|
|
115
|
+
- **不碰 key**:用宿主自己的 provider / 模型 / 凭据,插件里没有任何密钥。
|
|
116
|
+
- **看得见**:导演的每次判断都留在那个会话里,是对话历史也是日志。
|
|
117
|
+
- **不重复开窗**:会话 id 存在 `config.json` 的 `director.sessionId`,只有会话没了才新建。
|
|
118
|
+
- **有退路**:DSH 连不上(或你把 `director.transport` 设成 `api`)就退回直连模型 API,合成照常出声。
|
|
119
|
+
|
|
120
|
+
设置页那张卡里能看到:通道、导演窗口(会话 id)、DSH 地址与来源、会话是否就绪;右上角 **「换一个导演窗口」**
|
|
121
|
+
会清掉会话 id,下一次合成自动在 DeepSeek 里新开一个。
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
GET /api/voice-say/direct/session → 看现在开着哪个窗口
|
|
125
|
+
POST /api/voice-say/direct/session {reset:true} → 清掉,下次新建
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
## QQ 里的声音 = 设置页试听(0.5.0)
|
|
130
|
+
|
|
131
|
+
以前群里发出去的语音和这里试听**不是一个嗓子**,三个原因叠在一起:
|
|
132
|
+
|
|
133
|
+
1. **音源不同**:QQ 那边桥接固定用自己那份参考音频(`audio/大肥鱼.mp3` 裁 12 秒)克隆;
|
|
134
|
+
而设置页如果没选音色,`--voice` / `--ref-audio` 都不传,引擎走的是**内置默认说话人**。
|
|
135
|
+
2. **参数不同**:试听带着 `--instruction` + `--cfg-scale`,QQ 那条 `cfgScale: 1` 什么都不传。
|
|
136
|
+
3. **最后一道**:QQ 语音条要过 SILK 编码(网关做的),低码率会削高频、声音发闷——这条躲不掉,谁发都一样。
|
|
137
|
+
|
|
138
|
+
现在反过来:**QQ 那边先问插件**。桥接每次要发语音,先 `POST /api/voice-say/voice-plan` 拿一份「这句怎么念」:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
台词 ─┐
|
|
142
|
+
├─ 读音导演 → 打好标点的台词(spoken)
|
|
143
|
+
├─ 风格 → instructions + guidanceScale
|
|
144
|
+
└─ 音色 → voice(默认音色)或 refAudio(自定义参考音频)
|
|
145
|
+
↓
|
|
146
|
+
Breeze / 云端 → 音频 → SILK → QQ
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
于是三样东西对齐了:**标点、语气、嗓子**。想让它彻底一致,只要在设置页做一件事:
|
|
150
|
+
|
|
151
|
+
- 「自定义参考音频」传一段音频 → 存成 `<数据根>/ref/ref-24k.wav`,QQ 发语音就用它当 `--ref-audio`;
|
|
152
|
+
- 或者上传成音色再「设为默认」(音色同样是从你那段音频编出来的)。
|
|
153
|
+
|
|
154
|
+
两样都没设,才回落到桥接自带的克隆。桥接侧开关:`socialV2.voice.followPlugin`(`false` = 临时不跟插件)、
|
|
155
|
+
`socialV2.voice.pluginBaseUrl`(默认取 `dsh.baseUrl`)。桥接失败/超时一律 null 回落老路,不会把消息卡死。
|
|
156
|
+
|
|
99
157
|
## 装
|
|
100
158
|
|
|
101
159
|
```bash
|
|
@@ -145,6 +203,8 @@ dsh plugin --profile <profile> add dsh-voice-say
|
|
|
145
203
|
| `seed` | `42` | 换种子 = 换一种演绎 |
|
|
146
204
|
| `maxChars` | `300` | 单次合成字数上限 |
|
|
147
205
|
| `defaultVoice` | 空 | 不指定就用 `instructions` 现场设计嗓音 |
|
|
206
|
+
| `refAudio` | 空 | 自定义参考音频的 24k wav 路径(界面「就用这段当嗓子」会写它);没音色时用它克隆 |
|
|
207
|
+
| `refText` | 空 | 上面那段音频的逐字转写 |
|
|
148
208
|
| `cli` / `model` | 自动探测 | breeze-cli.exe 与 .gguf |
|
|
149
209
|
| `voicesDir` / `inboxDir` | 自动探测 | 音色目录 / 上传暂存目录 |
|
|
150
210
|
| `device` / `gpuIndex` | `vulkan` / `1` | 多显卡机器要钉住独显 |
|
|
@@ -155,10 +215,16 @@ dsh plugin --profile <profile> add dsh-voice-say
|
|
|
155
215
|
| `cloudGuidanceField` | `guidance_scale` | 有的服务叫 `cfg_scale` |
|
|
156
216
|
| `timeoutMs` | `600000` | 单次合成超时 |
|
|
157
217
|
| `director.enabled` | `true` | 合成前要不要先过读音导演 |
|
|
158
|
-
| `director.
|
|
159
|
-
| `director.
|
|
218
|
+
| `director.transport` | `dsh` | `dsh` = 在 DeepSeek 窗口里的专用会话里想;`api` = 直连模型 API |
|
|
219
|
+
| `director.dshBaseUrl` | 自动 | DSH 地址;不填就用 QQ 桥接记着的 `dsh.baseUrl` |
|
|
220
|
+
| `director.sessionId` | 自动 | 那个导演窗口的 id,第一次合成时建好写进来;清空 = 下次重开 |
|
|
221
|
+
| `director.sessionTitle` | `读音导演 · dsh-voice-say` | 新开会话用的标题(一眼能在 GUI 里认出来) |
|
|
222
|
+
| `director.sessionCwd` | 工作区 | 会话的工作目录 |
|
|
223
|
+
| `director.sessionTimeoutMs` | `60000` | 在会话里等一轮说完的上限 |
|
|
224
|
+
| `director.baseUrl` / `director.model` | `https://ai.xyclub.top/v1` / `deepseek-v4.1-flash` | 走 `api` 通道时用的模型(和 QQ 同一个) |
|
|
225
|
+
| `director.apiKey` / `apiKeyEnv` | 空 / `DPSK_API_KEY` | `api` 通道的 key,优先用它,其次环境变量,其次 `~/.dsh/.credentials.yaml` |
|
|
160
226
|
| `director.scope` | 自动 | 钉住某个会话的剧情与情绪(`group:xxx` / `private:xxx`) |
|
|
161
|
-
| `director.timeoutMs` | `20000` |
|
|
227
|
+
| `director.timeoutMs` | `20000` | `api` 通道超时就退回原文照念 |
|
|
162
228
|
| `lifeHome` / `emotionHome` / `bridgeState` | 自动探测 | 人设 / 情绪 / 剧情三份数据的目录 |
|
|
163
229
|
|
|
164
230
|
## HTTP
|
|
@@ -172,10 +238,15 @@ dsh plugin --profile <profile> add dsh-voice-say
|
|
|
172
238
|
| GET | `/api/voice-say/direct/context` | 导演手里有什么料(人设 / 情绪 / 剧情),不发模型请求 |
|
|
173
239
|
| POST | `/api/voice-say/direct` | 只算标点:`{text,styles,instructions,scope}` |
|
|
174
240
|
| POST | `/api/voice-say/direct/cache` | 清掉导演的标点缓存 |
|
|
241
|
+
| GET | `/api/voice-say/direct/session` | 看导演窗口开在哪个会话、DSH 是否连得上 |
|
|
242
|
+
| POST | `/api/voice-say/direct/session` | `{reset:true}` 清掉会话 id(下次合成自动新开);`{sessionId}` 指定窗口 |
|
|
175
243
|
| POST | `/api/voice-say/config` | 改配置(写进 `config.json`) |
|
|
176
244
|
| GET | `/api/voice-say/voices` | 列音色 |
|
|
177
245
|
| POST | `/api/voice-say/voices` | 上传音色:`{name,text,description,filename,data}`(`data` 为 base64)或 `{name,text,src}` |
|
|
178
246
|
| POST | `/api/voice-say/voices/delete` | `{name}` |
|
|
247
|
+
| POST | `/api/voice-say/voices/default` | `{name}` 设成默认音色(空串=取消),QQ 发语音也用它 |
|
|
248
|
+
| POST | `/api/voice-say/ref` | 自定义参考音频:`{filename,data}`(base64)或 `{src,text}`;`{clear:true}` 清掉 |
|
|
249
|
+
| POST | `/api/voice-say/voice-plan` | 只出「这句怎么念」:`{text,styles,instructions,scope,direct}` → `spoken/instructions/guidanceScale/voice/refAudio` |
|
|
179
250
|
| POST | `/api/voice-say/synth` | 合成:`{text,provider,voice,styles,instructions,guidanceScale,seed,direct,scope}` |
|
|
180
251
|
| POST | `/api/voice-say/preview` | 试听(同上,`text` 可省) |
|
|
181
252
|
| POST | `/api/voice-say/cloud/test` | 云端连通性(真发一次极短合成) |
|
package/client.js
CHANGED
|
@@ -124,11 +124,13 @@ window.__ModuleLoader__.load({
|
|
|
124
124
|
})
|
|
125
125
|
const [styleList, setStyleList] = useState([])
|
|
126
126
|
const [directorOn, setDirectorOn] = useState(true)
|
|
127
|
+
const [directTransport, setDirectTransport] = useState('dsh')
|
|
127
128
|
const [directBusy, setDirectBusy] = useState('')
|
|
128
129
|
const [directRes, setDirectRes] = useState(null)
|
|
129
130
|
const [directCtx, setDirectCtx] = useState(null)
|
|
130
131
|
const [voices, setVoices] = useState([])
|
|
131
132
|
const [up, setUp] = useState({ name: '', text: '', description: '', file: null })
|
|
133
|
+
const [refUp, setRefUp] = useState({ text: '', file: null })
|
|
132
134
|
const [text, setText] = useState('哼,才不是特意来跟你说话的呢,笨蛋')
|
|
133
135
|
const [busy, setBusy] = useState('')
|
|
134
136
|
const [err, setErr] = useState('')
|
|
@@ -139,7 +141,10 @@ window.__ModuleLoader__.load({
|
|
|
139
141
|
const mergeStatus = useCallback((s) => {
|
|
140
142
|
setSt(s)
|
|
141
143
|
setVoices(s?.local?.voices || [])
|
|
142
|
-
if (s?.director)
|
|
144
|
+
if (s?.director) {
|
|
145
|
+
setDirectorOn(s.director.enabled !== false)
|
|
146
|
+
if (s.director.transport) setDirectTransport(s.director.transport)
|
|
147
|
+
}
|
|
143
148
|
setForm((f) => ({
|
|
144
149
|
...f,
|
|
145
150
|
provider: s?.provider || f.provider,
|
|
@@ -174,7 +179,7 @@ window.__ModuleLoader__.load({
|
|
|
174
179
|
try {
|
|
175
180
|
const payload = { ...form }
|
|
176
181
|
if (!payload.cloudApiKey) delete payload.cloudApiKey
|
|
177
|
-
payload.director = { enabled: directorOn }
|
|
182
|
+
payload.director = { enabled: directorOn, transport: directTransport }
|
|
178
183
|
const r = await jpost('/config', payload)
|
|
179
184
|
if (r?.status) mergeStatus(r.status)
|
|
180
185
|
setNotice('已保存,立刻生效')
|
|
@@ -207,6 +212,30 @@ window.__ModuleLoader__.load({
|
|
|
207
212
|
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
208
213
|
}
|
|
209
214
|
|
|
215
|
+
/** 上传一段音频当「参考音频」用:不编成音色,直接拿它克隆。
|
|
216
|
+
* QQ 发语音时桥接会问插件,拿到这个文件当 --ref-audio —— 所以群里那个嗓子就是你上传的这段。 */
|
|
217
|
+
const uploadRef = async () => {
|
|
218
|
+
setBusy('ref'); setErr(''); setNotice('')
|
|
219
|
+
try {
|
|
220
|
+
if (!refUp.file) throw new Error('先选一个音频文件(wav/mp3/m4a/flac/ogg,5~15 秒最稳)')
|
|
221
|
+
if (refUp.file.size > 20 * 1024 * 1024) throw new Error('文件超过 20MB,换短一点的一段')
|
|
222
|
+
const data = await readFileBase64(refUp.file)
|
|
223
|
+
const r = await jpost('/ref', { text: refUp.text.trim(), filename: refUp.file.name, data })
|
|
224
|
+
if (r?.status) mergeStatus(r.status)
|
|
225
|
+
setNotice(r?.warning || '参考音频已设好:试听和 QQ 发出去的都是这一段的声音')
|
|
226
|
+
setRefUp({ text: '', file: null })
|
|
227
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const clearRef = async () => {
|
|
231
|
+
setBusy('ref'); setErr(''); setNotice('')
|
|
232
|
+
try {
|
|
233
|
+
const r = await jpost('/ref', { clear: true })
|
|
234
|
+
if (r?.status) mergeStatus(r.status)
|
|
235
|
+
setNotice('已清除参考音频(回到引擎默认 / 桥接自带的参考音频)')
|
|
236
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
237
|
+
}
|
|
238
|
+
|
|
210
239
|
const removeVoice = async (name) => {
|
|
211
240
|
setBusy('del'); setErr(''); setNotice('')
|
|
212
241
|
try {
|
|
@@ -216,6 +245,17 @@ window.__ModuleLoader__.load({
|
|
|
216
245
|
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
217
246
|
}
|
|
218
247
|
|
|
248
|
+
/** 设为默认音色:写进插件配置。QQ 发语音时桥接会先问插件,所以这里设了,群里也是这个嗓子。 */
|
|
249
|
+
const setDefaultVoice = async (name) => {
|
|
250
|
+
setBusy('def'); setErr(''); setNotice('')
|
|
251
|
+
try {
|
|
252
|
+
const r = await jpost('/voices/default', { name })
|
|
253
|
+
setForm((f) => ({ ...f, defaultVoice: name }))
|
|
254
|
+
if (r?.status) mergeStatus(r.status)
|
|
255
|
+
setNotice(name ? `默认音色 → ${name}(QQ 发语音也会用它)` : '已取消默认音色(回到参考音频克隆)')
|
|
256
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
257
|
+
}
|
|
258
|
+
|
|
219
259
|
const styleById = (id) => styleList.find((s) => s.id === id)
|
|
220
260
|
|
|
221
261
|
/** 多选风格:选上就把它建议的强度顶上去(用户仍可手动改)。 */
|
|
@@ -267,7 +307,7 @@ window.__ModuleLoader__.load({
|
|
|
267
307
|
} catch (e) { setErr(e?.message || String(e)) } finally { setDirectBusy('') }
|
|
268
308
|
}
|
|
269
309
|
|
|
270
|
-
/**
|
|
310
|
+
/** 一次性看一眼导演手里到底有什么料(人设/情绪/聊天),不发模型请求。 */
|
|
271
311
|
const loadContext = async () => {
|
|
272
312
|
setDirectBusy('ctx'); setErr('')
|
|
273
313
|
try {
|
|
@@ -276,6 +316,16 @@ window.__ModuleLoader__.load({
|
|
|
276
316
|
} catch (e) { setErr(e?.message || String(e)) } finally { setDirectBusy('') }
|
|
277
317
|
}
|
|
278
318
|
|
|
319
|
+
/** 换一个导演窗口:清掉会话 id,下一次合成会在 DeepSeek 窗口里新建一个。 */
|
|
320
|
+
const newSession = async () => {
|
|
321
|
+
setDirectBusy('session'); setErr(''); setNotice('')
|
|
322
|
+
try {
|
|
323
|
+
await jpost('/direct/session', { reset: true })
|
|
324
|
+
await load()
|
|
325
|
+
setNotice('已清空:下一次合成会在 DeepSeek 窗口里新开一个「读音导演」会话')
|
|
326
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setDirectBusy('') }
|
|
327
|
+
}
|
|
328
|
+
|
|
279
329
|
const local = st?.local || {}
|
|
280
330
|
const cloud = st?.cloud || {}
|
|
281
331
|
const director = st?.director || {}
|
|
@@ -346,22 +396,48 @@ window.__ModuleLoader__.load({
|
|
|
346
396
|
directBusy === 'direct' ? '算标点中…' : '试算标点'),
|
|
347
397
|
h('button', { 'data-vs-btn': '', disabled: directBusy === 'ctx', onClick: loadContext },
|
|
348
398
|
directBusy === 'ctx' ? '读取中…' : '看上下文'),
|
|
399
|
+
h('button', { 'data-vs-btn': '', disabled: directBusy === 'session', onClick: newSession },
|
|
400
|
+
directBusy === 'session' ? '清空中…' : '换一个导演窗口'),
|
|
349
401
|
),
|
|
350
402
|
h('div', { 'data-vs-b': '' },
|
|
351
403
|
h('div', { 'data-vs-sub': '' },
|
|
352
404
|
'合成之前,先把台词交给「和 QQ 同一个大脑」:它看得到她的人设、此刻的七情、刚才在聊什么,'
|
|
353
405
|
+ '据此重排标点与停顿——哪句断、哪里停一口气、结尾是句号还是省略号。它只许动标点,字词改一个就作废、退回原文。'),
|
|
354
406
|
h('div', { 'data-vs-grid': '' },
|
|
407
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '通道'),
|
|
408
|
+
h('select', {
|
|
409
|
+
'data-vs-select': '', value: directTransport,
|
|
410
|
+
onChange: (e) => { setDirectTransport(e.target.value); setNotice('通道改了,记得点下方「保存」') },
|
|
411
|
+
},
|
|
412
|
+
h('option', { value: 'dsh' }, 'DeepSeek 窗口里的专用会话'),
|
|
413
|
+
h('option', { value: 'api' }, '直连模型 API(不建会话)'))),
|
|
414
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '导演窗口'),
|
|
415
|
+
directTransport === 'dsh'
|
|
416
|
+
? h('span', { 'data-vs-v': '', 'data-vs-mono': '' },
|
|
417
|
+
(director.dsh?.sessionId || '还没开(第一次合成时自动开)'))
|
|
418
|
+
: h('span', { 'data-vs-v': '' }, '不走会话')),
|
|
419
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, 'DSH 地址'),
|
|
420
|
+
h('span', { 'data-vs-v': '', 'data-vs-mono': '' },
|
|
421
|
+
(director.dsh?.baseUrl || '—') + (director.dsh?.baseFrom ? `(${director.dsh.baseFrom})` : ''))),
|
|
422
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '会话就绪'),
|
|
423
|
+
h(Badge, { ok: director.dsh?.ready }, director.dsh?.ready
|
|
424
|
+
? '能连上'
|
|
425
|
+
: (director.dsh?.baseUrl ? '连不上(密钥?)' : '不知道地址'))),
|
|
355
426
|
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '模型'),
|
|
356
|
-
h('span', { 'data-vs-v': '', 'data-vs-mono': '' }, director.model || '—')),
|
|
427
|
+
h('span', { 'data-vs-v': '', 'data-vs-mono': '' }, directTransport === 'dsh' ? '宿主当前模型' : (director.model || '—'))),
|
|
357
428
|
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, 'key'),
|
|
358
|
-
|
|
359
|
-
|
|
429
|
+
directTransport === 'dsh'
|
|
430
|
+
? h('span', { 'data-vs-v': '' }, '用宿主的凭据,插件不碰 key')
|
|
431
|
+
: h(Badge, { ok: director.hasKey }, director.hasKey ? (director.keyFrom || '已配置') : '没找到')),
|
|
432
|
+
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '看的会话'),
|
|
360
433
|
h('span', { 'data-vs-v': '' }, director.scope || '自动(最近那个)')),
|
|
361
434
|
h('div', { 'data-vs-kv': '' }, h('span', { 'data-vs-k': '' }, '超时'),
|
|
362
|
-
h('span', { 'data-vs-v': '' }, `${Math.round((director.timeoutMs || 20000) / 1000)} 秒(超时就退回原文照念)`)),
|
|
435
|
+
h('span', { 'data-vs-v': '' }, `${Math.round(((directTransport === 'dsh' ? director.dsh?.timeoutMs : director.timeoutMs) || 20000) / 1000)} 秒(超时就退回原文照念)`)),
|
|
363
436
|
),
|
|
364
|
-
director.ready ? null : h('div', { 'data-vs-err': '' },
|
|
437
|
+
director.ready ? null : h('div', { 'data-vs-err': '' },
|
|
438
|
+
directTransport === 'dsh'
|
|
439
|
+
? '导演的 DeepSeek 会话连不上(读不到 DSH 地址或会话密钥)。合成照常,只是不重排标点;也可以在下面把通道切到「直连模型 API」。'
|
|
440
|
+
: '导演缺 key:设置里填 apiKey,或让 DPSK_API_KEY 在环境里;没有 key 时合成照常,只是不重排标点。'),
|
|
365
441
|
|
|
366
442
|
directCtx
|
|
367
443
|
? h('div', { 'data-vs-say': '' },
|
|
@@ -388,7 +464,9 @@ window.__ModuleLoader__.load({
|
|
|
388
464
|
directRes.emotion ? `· 读出来的情绪:${directRes.emotion}` : '',
|
|
389
465
|
directRes.elapsedMs ? `· ${(directRes.elapsedMs / 1000).toFixed(1)} 秒` : '',
|
|
390
466
|
directRes.cached ? '· 命中缓存' : '',
|
|
467
|
+
directRes.transport === 'dsh' ? '· 在 DeepSeek 窗口里想的' : '',
|
|
391
468
|
].filter(Boolean).join(' ')),
|
|
469
|
+
directRes.sessionId ? h('div', { 'data-vs-sub': '', 'data-vs-mono': '' }, `会话:${directRes.sessionId}`) : null,
|
|
392
470
|
directRes.notes ? h('div', { 'data-vs-sub': '' }, `导演说明:${directRes.notes}`) : null,
|
|
393
471
|
directRes.reason && !directRes.applied ? h('div', { 'data-vs-err': '' }, `原因:${directRes.reason}`) : null,
|
|
394
472
|
directRes.rejected ? h('div', { 'data-vs-sub': '' }, `它想改成(已作废):${directRes.rejected}`) : null,
|
|
@@ -569,10 +647,50 @@ window.__ModuleLoader__.load({
|
|
|
569
647
|
),
|
|
570
648
|
),
|
|
571
649
|
|
|
650
|
+
// ── 自定义参考音频(不保存成音色) ──
|
|
651
|
+
h('div', { 'data-vs-card': '' },
|
|
652
|
+
h('div', { 'data-vs-h': '' }, '自定义参考音频(直接当嗓子用,不编成音色)'),
|
|
653
|
+
h('div', { 'data-vs-b': '' },
|
|
654
|
+
h('div', { 'data-vs-sub': '' },
|
|
655
|
+
'这里传一段音频,插件就把它存成 `<数据根>/ref/ref-24k.wav`,'
|
|
656
|
+
+ '**试听和 QQ 群里发出去的语音都用它当参考**(桥接会先问插件要这个文件)——'
|
|
657
|
+
+ '所以「QQ 里的声音」= 你上传的这段音频的音色。'),
|
|
658
|
+
st?.reference?.exists
|
|
659
|
+
? h('div', { 'data-vs-kv': '' },
|
|
660
|
+
h('span', { 'data-vs-k': '' }, '当前参考音频'),
|
|
661
|
+
h('span', { 'data-vs-mono': '', style: { fontSize: 11 } }, st.reference.refAudio),
|
|
662
|
+
h('span', { 'data-vs-sub': '' },
|
|
663
|
+
st.reference.used ? '生效中(没有默认音色,就用它)' : '已存,但当前用的是默认音色'),
|
|
664
|
+
h('button', { 'data-vs-btn': '', disabled: busy === 'ref', onClick: clearRef }, '清除'))
|
|
665
|
+
: h('div', { 'data-vs-sub': '' }, '还没设置参考音频。'),
|
|
666
|
+
h('div', { 'data-vs-kv': '' },
|
|
667
|
+
h('span', { 'data-vs-k': '' }, '逐字转写(这段音频里一字不差说的话,强烈建议给)'),
|
|
668
|
+
h('textarea', {
|
|
669
|
+
'data-vs-area': '', value: refUp.text,
|
|
670
|
+
placeholder: '把这段音频里说的话原样打一遍;不给的话引擎得靠猜,音色会不稳。',
|
|
671
|
+
onChange: (e) => setRefUp({ ...refUp, text: e.target.value }),
|
|
672
|
+
}),
|
|
673
|
+
),
|
|
674
|
+
h('div', { 'data-vs-row': '' },
|
|
675
|
+
h('input', {
|
|
676
|
+
type: 'file', accept: 'audio/*', style: { fontSize: 11 },
|
|
677
|
+
onChange: (e) => setRefUp({ ...refUp, file: e.target.files?.[0] || null }),
|
|
678
|
+
}),
|
|
679
|
+
h('span', { 'data-vs-sub': '' }, refUp.file ? `${refUp.file.name} · ${Math.round(refUp.file.size / 1024)} KB` : '5~15 秒、安静环境、单人说话'),
|
|
680
|
+
h('button', { 'data-vs-btn': '', 'data-vs-primary': '', disabled: busy === 'ref', onClick: uploadRef },
|
|
681
|
+
busy === 'ref' ? '处理中…' : '就用这段当嗓子'),
|
|
682
|
+
),
|
|
683
|
+
h('div', { 'data-vs-sub': '' }, '非 wav 会先用 ffmpeg 转 24k 单声道,只取前 15 秒。'),
|
|
684
|
+
),
|
|
685
|
+
),
|
|
686
|
+
|
|
572
687
|
// ── 音色列表 ──
|
|
573
688
|
h('div', { 'data-vs-card': '' },
|
|
574
689
|
h('div', { 'data-vs-h': '' }, `已保存音色(${voices.length})`),
|
|
575
690
|
h('div', { 'data-vs-b': '' },
|
|
691
|
+
h('div', { 'data-vs-sub': '' },
|
|
692
|
+
'「设为默认」会写进插件配置:**QQ 群里发语音时,桥接先问插件,嗓子、语气、标点都照这里来**。'
|
|
693
|
+
+ '不设默认时用上面的「自定义参考音频」;两样都没有,QQ 才回落到桥接自带的 `audio/大肥鱼.mp3`。'),
|
|
576
694
|
voices.length === 0
|
|
577
695
|
? h('div', { 'data-vs-sub': '' }, '还没有音色。上传一段参考音频,或者干脆不给音色、只写 instructions 让模型现编一个。')
|
|
578
696
|
: voices.map((v) => h('div', {
|
|
@@ -581,10 +699,17 @@ window.__ModuleLoader__.load({
|
|
|
581
699
|
h('span', { 'data-vs-mono': '', style: { minWidth: 110 } }, v.name),
|
|
582
700
|
h('span', { 'data-vs-sub': '' }, `${v.seconds ?? '?'}s`),
|
|
583
701
|
h('span', { 'data-vs-sub': '', style: { flex: 1 } }, v.description || v.refText || ''),
|
|
702
|
+
form.defaultVoice === v.name
|
|
703
|
+
? h('span', { 'data-vs-sub': '', style: { color: '#5b8def' } }, '默认')
|
|
704
|
+
: null,
|
|
705
|
+
h('button', {
|
|
706
|
+
'data-vs-btn': '', disabled: busy === 'def',
|
|
707
|
+
onClick: () => setDefaultVoice(form.defaultVoice === v.name ? '' : v.name),
|
|
708
|
+
}, form.defaultVoice === v.name ? '取消默认' : '设为默认'),
|
|
584
709
|
h('button', {
|
|
585
710
|
'data-vs-btn': '',
|
|
586
711
|
onClick: () => setForm({ ...form, defaultVoice: v.name }),
|
|
587
|
-
},
|
|
712
|
+
}, '只用于试听'),
|
|
588
713
|
h('button', {
|
|
589
714
|
'data-vs-btn': '', disabled: busy === 'del',
|
|
590
715
|
onClick: () => removeVoice(v.name),
|
package/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import fs from 'node:fs'
|
|
|
24
24
|
import os from 'node:os'
|
|
25
25
|
import path from 'node:path'
|
|
26
26
|
import { loadHostDeps } from './lib/hostdeps.mjs'
|
|
27
|
-
import { breezeOptions, probe, listVoices, addVoice, deleteVoice, synth as breezeSynth, resolveAudioInput } from './lib/breeze.mjs'
|
|
27
|
+
import { breezeOptions, probe, listVoices, addVoice, deleteVoice, synth as breezeSynth, resolveAudioInput, normalizeRef } from './lib/breeze.mjs'
|
|
28
28
|
import { cloudOptions, synthCloud, testCloud, speechUrl } from './lib/cloud.mjs'
|
|
29
29
|
import { catalog, compose, pickStyles } from './lib/styles.mjs'
|
|
30
30
|
import { direct as directText, gatherContext, probeDirector, clearCache as clearDirectorCache } from './lib/director.mjs'
|
|
@@ -32,7 +32,7 @@ import { direct as directText, gatherContext, probeDirector, clearCache as clear
|
|
|
32
32
|
export const name = 'dsh-voice-say'
|
|
33
33
|
export const inject = ['webServer', 'tools']
|
|
34
34
|
|
|
35
|
-
const PLUGIN_VERSION = '0.
|
|
35
|
+
const PLUGIN_VERSION = '0.6.0'
|
|
36
36
|
const DEFAULT_INSTRUCTIONS = '说话自然一点,带点傲娇和不耐烦,别太热情。'
|
|
37
37
|
const DEFAULT_PREVIEW_TEXT = '哼,才不是特意来跟你说话的呢,笨蛋。'
|
|
38
38
|
const MAX_BODY = 32 * 1024 * 1024
|
|
@@ -70,6 +70,9 @@ export async function apply(ctx, config = {}) {
|
|
|
70
70
|
maxChars: 300,
|
|
71
71
|
defaultVoice: '',
|
|
72
72
|
styles: [],
|
|
73
|
+
// 自定义参考音频:不编成音色也能直接当参考(QQ 发出去的嗓子 = 你上传的这段)
|
|
74
|
+
refAudio: '',
|
|
75
|
+
refText: '',
|
|
73
76
|
// 本地
|
|
74
77
|
cli: '',
|
|
75
78
|
model: '',
|
|
@@ -121,7 +124,38 @@ export async function apply(ctx, config = {}) {
|
|
|
121
124
|
})
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 导演会话的「记下来」钩子:DSH 里那个专用会话只建一次,
|
|
129
|
+
* 建好后 sessionId 写进 config.json,之后每次都进同一个窗口。
|
|
130
|
+
*/
|
|
131
|
+
const directorHooks = {
|
|
132
|
+
async onSession(sessionId) {
|
|
133
|
+
try {
|
|
134
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}) }
|
|
135
|
+
const prev = next.director && typeof next.director === 'object' && !Array.isArray(next.director)
|
|
136
|
+
? next.director : {}
|
|
137
|
+
next.director = { ...prev, sessionId: String(sessionId || '') }
|
|
138
|
+
writeJson(uiConfigFile, next)
|
|
139
|
+
log('导演会话 →', sessionId)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
log('导演会话记不下来:', error?.message || error)
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
124
146
|
/* ───────────────────── 状态 ───────────────────── */
|
|
147
|
+
/**
|
|
148
|
+
* 自定义参考音频:上传过、文件还在,就返回它。
|
|
149
|
+
* 有默认音色时不需要它(音色本身就是从参考音频编出来的),所以只在没音色时用。
|
|
150
|
+
*/
|
|
151
|
+
function refOf(c) {
|
|
152
|
+
const refAudio = String(c.refAudio || '').trim()
|
|
153
|
+
const refText = String(c.refText || '').trim()
|
|
154
|
+
if (!refAudio) return null
|
|
155
|
+
if (!fileOk(refAudio)) return null
|
|
156
|
+
return { refAudio, refText }
|
|
157
|
+
}
|
|
158
|
+
|
|
125
159
|
async function statusOf() {
|
|
126
160
|
const c = cfg()
|
|
127
161
|
const lo = localOpts()
|
|
@@ -146,6 +180,12 @@ export async function apply(ctx, config = {}) {
|
|
|
146
180
|
maxChars: Number(c.maxChars) || 300,
|
|
147
181
|
},
|
|
148
182
|
local: { ...localInfo, device: lo.device, gpuIndex: lo.gpuIndex, timeoutMs: lo.timeoutMs },
|
|
183
|
+
reference: {
|
|
184
|
+
refAudio: String(c.refAudio || ''),
|
|
185
|
+
refText: String(c.refText || ''),
|
|
186
|
+
exists: Boolean(refOf(c)),
|
|
187
|
+
used: Boolean(refOf(c)) && !String(c.defaultVoice || '').trim(),
|
|
188
|
+
},
|
|
149
189
|
director: probeDirector(c),
|
|
150
190
|
cloud: {
|
|
151
191
|
baseUrl: co.baseUrl,
|
|
@@ -213,6 +253,7 @@ export async function apply(ctx, config = {}) {
|
|
|
213
253
|
styles: vs.styles,
|
|
214
254
|
instructions: vs.instructions,
|
|
215
255
|
scope: opts.scope || c.director?.scope || '',
|
|
256
|
+
hooks: directorHooks,
|
|
216
257
|
})
|
|
217
258
|
if (direction.applied) spoken = direction.text
|
|
218
259
|
}
|
|
@@ -240,8 +281,18 @@ export async function apply(ctx, config = {}) {
|
|
|
240
281
|
const r = await synthCloud(cloudOpts(), { text: spoken, outFile, voice, instructions, guidanceScale })
|
|
241
282
|
return { ...r, ...styleMeta, ...dirMeta, file: path.basename(r.file), size: r.bytes }
|
|
242
283
|
}
|
|
243
|
-
|
|
244
|
-
|
|
284
|
+
// 没有音色 → 有自定义参考音频就用它(这样试听和 QQ 里发出去的是同一个嗓子)
|
|
285
|
+
const ref = voice ? null : refOf(c)
|
|
286
|
+
const r = await breezeSynth(localOpts(), {
|
|
287
|
+
text: spoken, outFile, voice, instructions, guidanceScale, seed,
|
|
288
|
+
refAudio: ref?.refAudio, refText: ref?.refText,
|
|
289
|
+
})
|
|
290
|
+
return {
|
|
291
|
+
...r, ...styleMeta, ...dirMeta,
|
|
292
|
+
refAudio: ref ? ref.refAudio : '',
|
|
293
|
+
file: path.basename(r.file),
|
|
294
|
+
size: fileOk(r.file) ? fs.statSync(r.file).size : null,
|
|
295
|
+
}
|
|
245
296
|
}
|
|
246
297
|
|
|
247
298
|
/** 试听:给一小段文字,用**当前设置**合出来,只为了听效果。 */
|
|
@@ -257,6 +308,64 @@ export async function apply(ctx, config = {}) {
|
|
|
257
308
|
}
|
|
258
309
|
}
|
|
259
310
|
|
|
311
|
+
/**
|
|
312
|
+
* 只出「这一句该怎么念」,不合成。
|
|
313
|
+
* 给 QQ 桥接用:让群里发出去的语音和设置页试听是**同一个嗓子、同一套语气、同一份标点**。
|
|
314
|
+
*/
|
|
315
|
+
async function voicePlan(opts = {}) {
|
|
316
|
+
const c = cfg()
|
|
317
|
+
const body = String(opts.text ?? '').trim()
|
|
318
|
+
if (!body) throw new Error('text 不能为空')
|
|
319
|
+
const vs = resolveVoiceStyle(c, opts)
|
|
320
|
+
const provider = String(opts.provider || c.provider || 'local').toLowerCase() === 'cloud' ? 'cloud' : 'local'
|
|
321
|
+
const voice = String(opts.voice ?? c.defaultVoice ?? '').trim()
|
|
322
|
+
const seed = Number.isFinite(Number(opts.seed)) ? Number(opts.seed) : Number(c.seed) || 42
|
|
323
|
+
const wantDirect = opts.direct === undefined
|
|
324
|
+
? (c.director ? c.director.enabled !== false : true)
|
|
325
|
+
: Boolean(opts.direct)
|
|
326
|
+
let spoken = body
|
|
327
|
+
let direction = null
|
|
328
|
+
if (wantDirect) {
|
|
329
|
+
direction = await directText(c, {
|
|
330
|
+
text: body,
|
|
331
|
+
styles: vs.styles,
|
|
332
|
+
instructions: vs.instructions,
|
|
333
|
+
scope: opts.scope || c.director?.scope || '',
|
|
334
|
+
hooks: directorHooks,
|
|
335
|
+
})
|
|
336
|
+
if (direction.applied) spoken = direction.text
|
|
337
|
+
}
|
|
338
|
+
const lo = provider === 'local' ? localOpts() : null
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
provider,
|
|
342
|
+
engine: provider === 'local' ? 'breeze' : 'cloud',
|
|
343
|
+
source: body,
|
|
344
|
+
spoken,
|
|
345
|
+
directed: Boolean(direction?.applied),
|
|
346
|
+
instructions: vs.instructions,
|
|
347
|
+
guidanceScale: vs.guidanceScale,
|
|
348
|
+
styles: vs.styles,
|
|
349
|
+
styleNames: vs.styleNames,
|
|
350
|
+
seed,
|
|
351
|
+
voice,
|
|
352
|
+
voicesDir: lo ? lo.voicesDir : '',
|
|
353
|
+
refAudio: voice ? '' : (refOf(c)?.refAudio || ''),
|
|
354
|
+
refText: voice ? '' : (refOf(c)?.refText || ''),
|
|
355
|
+
direction: direction
|
|
356
|
+
? {
|
|
357
|
+
ok: direction.ok,
|
|
358
|
+
applied: direction.applied,
|
|
359
|
+
notes: direction.notes,
|
|
360
|
+
emotion: direction.emotion,
|
|
361
|
+
reason: direction.reason,
|
|
362
|
+
cached: Boolean(direction.cached),
|
|
363
|
+
}
|
|
364
|
+
: null,
|
|
365
|
+
at: Date.now(),
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
260
369
|
/* ───────────────────── 工具 ───────────────────── */
|
|
261
370
|
const textOut = {
|
|
262
371
|
schema: { type: 'object', properties: {}, additionalProperties: true },
|
|
@@ -359,6 +468,7 @@ export async function apply(ctx, config = {}) {
|
|
|
359
468
|
styles: vs.styles,
|
|
360
469
|
instructions: vs.instructions,
|
|
361
470
|
scope: args?.scope || c.director?.scope || '',
|
|
471
|
+
hooks: directorHooks,
|
|
362
472
|
})
|
|
363
473
|
return JSON.parse(JSON.stringify({ ...r, styleNames: vs.styleNames }))
|
|
364
474
|
},
|
|
@@ -454,6 +564,7 @@ export async function apply(ctx, config = {}) {
|
|
|
454
564
|
|
|
455
565
|
const ALLOW = [
|
|
456
566
|
'provider', 'instructions', 'guidanceScale', 'seed', 'maxChars', 'defaultVoice', 'styles',
|
|
567
|
+
'refAudio', 'refText',
|
|
457
568
|
'cli', 'model', 'device', 'gpuIndex', 'voicesDir', 'inboxDir', 'ffmpeg', 'maxRefSeconds', 'timeoutMs',
|
|
458
569
|
'cloudBaseUrl', 'cloudApiKey', 'cloudModel', 'cloudVoiceId', 'cloudStyle', 'cloudFormat', 'cloudGuidanceField',
|
|
459
570
|
'director', 'lifeHome', 'emotionHome', 'bridgeState',
|
|
@@ -493,6 +604,7 @@ export async function apply(ctx, config = {}) {
|
|
|
493
604
|
styles: vs.styles,
|
|
494
605
|
instructions: vs.instructions,
|
|
495
606
|
scope: b.scope || c.director?.scope || '',
|
|
607
|
+
hooks: directorHooks,
|
|
496
608
|
})
|
|
497
609
|
return sendJson(res, 200, { ok: true, ...r, styleNames: vs.styleNames })
|
|
498
610
|
}
|
|
@@ -502,6 +614,21 @@ export async function apply(ctx, config = {}) {
|
|
|
502
614
|
return sendJson(res, 200, { ok: true, cleared: true })
|
|
503
615
|
}
|
|
504
616
|
|
|
617
|
+
// 导演会话:看它现在开在哪个窗口,或者换一个(reset=true 清掉,下次自动新建)
|
|
618
|
+
if (sub === 'direct/session') {
|
|
619
|
+
if (req.method === 'POST') {
|
|
620
|
+
const b = await readBody(req)
|
|
621
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}) }
|
|
622
|
+
const prev = next.director && typeof next.director === 'object' && !Array.isArray(next.director)
|
|
623
|
+
? next.director : {}
|
|
624
|
+
next.director = { ...prev, sessionId: b.reset ? '' : String(b.sessionId || '') }
|
|
625
|
+
writeJson(uiConfigFile, next)
|
|
626
|
+
log(b.reset ? '导演会话已清空(下次合成时新开一个窗口)' : `导演会话 → ${next.director.sessionId}`)
|
|
627
|
+
return sendJson(res, 200, { ok: true, director: probeDirector(cfg()) })
|
|
628
|
+
}
|
|
629
|
+
return sendJson(res, 200, { ok: true, director: probeDirector(cfg()) })
|
|
630
|
+
}
|
|
631
|
+
|
|
505
632
|
if (req.method === 'POST' && sub === 'config') {
|
|
506
633
|
const b = await readBody(req)
|
|
507
634
|
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}) }
|
|
@@ -549,6 +676,67 @@ export async function apply(ctx, config = {}) {
|
|
|
549
676
|
return sendJson(res, 200, { ok: true, rows: await listVoices(lo) })
|
|
550
677
|
}
|
|
551
678
|
|
|
679
|
+
// 自定义参考音频:上传一段音频直接当参考用(不编成音色)。
|
|
680
|
+
// body: { src?(本机路径) | data?(base64), filename?, text?(逐字转写), clear?(清掉) }
|
|
681
|
+
if (req.method === 'POST' && (sub === 'ref' || sub === 'reference')) {
|
|
682
|
+
const b = await readBody(req)
|
|
683
|
+
const lo = localOpts()
|
|
684
|
+
if (b.clear) {
|
|
685
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}), refAudio: '', refText: '' }
|
|
686
|
+
writeJson(uiConfigFile, next)
|
|
687
|
+
log('参考音频已清除')
|
|
688
|
+
return sendJson(res, 200, { ok: true, cleared: true, status: await statusOf() })
|
|
689
|
+
}
|
|
690
|
+
let audioPath = String(b.src || '').trim()
|
|
691
|
+
if (!audioPath && b.data) {
|
|
692
|
+
const filename = path.basename(String(b.filename || `ref-${Date.now()}.wav`))
|
|
693
|
+
const inbox = ensureDir(lo.inboxDir || path.join(dataRoot, 'inbox'))
|
|
694
|
+
const target = path.join(inbox, `${Date.now()}-${filename}`)
|
|
695
|
+
fs.writeFileSync(target, Buffer.from(String(b.data), 'base64'))
|
|
696
|
+
audioPath = target
|
|
697
|
+
}
|
|
698
|
+
if (!audioPath) { sendJson(res, 400, { ok: false, error: '要么给 src(本机路径),要么给 data(base64 音频)' }); return }
|
|
699
|
+
const src = resolveAudioInput(lo, audioPath)
|
|
700
|
+
const refDir = ensureDir(path.join(dataRoot, 'ref'))
|
|
701
|
+
const dst = path.join(refDir, 'ref-24k.wav')
|
|
702
|
+
const nr = await normalizeRef(lo, src, dst)
|
|
703
|
+
if (!nr.ok) return sendJson(res, 400, { ok: false, error: nr.error || '音频转换失败' })
|
|
704
|
+
const refText = String(b.text || '').trim()
|
|
705
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}), refAudio: dst, refText }
|
|
706
|
+
writeJson(uiConfigFile, next)
|
|
707
|
+
log('参考音频 →', dst)
|
|
708
|
+
return sendJson(res, 200, {
|
|
709
|
+
ok: true,
|
|
710
|
+
refAudio: dst,
|
|
711
|
+
refText,
|
|
712
|
+
warning: refText ? '' : '没给逐字转写(ref_text):引擎得靠猜这段音频在说什么,音色会不稳',
|
|
713
|
+
status: await statusOf(),
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// 把某个音色设成默认(QQ 发语音也会用它)—— 设空字符串 = 取消默认
|
|
718
|
+
if (req.method === 'POST' && (sub === 'voices/default' || sub === 'voice/default')) {
|
|
719
|
+
const b = await readBody(req)
|
|
720
|
+
const lo = localOpts()
|
|
721
|
+
const want = String(b.name || '').trim()
|
|
722
|
+
if (want) {
|
|
723
|
+
const rows = await listVoices(lo)
|
|
724
|
+
if (!rows.some((r) => r.name === want)) {
|
|
725
|
+
return sendJson(res, 404, { ok: false, error: `没有这个音色:${want}`, rows })
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}), defaultVoice: want }
|
|
729
|
+
writeJson(uiConfigFile, next)
|
|
730
|
+
log('默认音色 →', want || '(空)')
|
|
731
|
+
return sendJson(res, 200, { ok: true, defaultVoice: want, status: await statusOf() })
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// 只出「这一句该怎么念」,不合成:QQ 桥接用它对齐嗓子/语气/标点
|
|
735
|
+
if (req.method === 'POST' && (sub === 'voice-plan' || sub === 'plan')) {
|
|
736
|
+
const b = await readBody(req)
|
|
737
|
+
return sendJson(res, 200, await voicePlan(b))
|
|
738
|
+
}
|
|
739
|
+
|
|
552
740
|
if (req.method === 'POST' && (sub === 'synth' || sub === 'say')) {
|
|
553
741
|
const b = await readBody(req)
|
|
554
742
|
const r = await synth(b.text, {
|
|
@@ -600,8 +788,8 @@ export async function apply(ctx, config = {}) {
|
|
|
600
788
|
sendJson(res, 404, {
|
|
601
789
|
ok: false,
|
|
602
790
|
error: '没有这个接口',
|
|
603
|
-
have: ['status', 'styles', 'config', 'voices', 'voices/delete', 'synth', 'preview', 'cloud/test', 'audio',
|
|
604
|
-
'direct', 'direct/context'],
|
|
791
|
+
have: ['status', 'styles', 'config', 'voices', 'voices/delete', 'voices/default', 'ref', 'synth', 'preview', 'cloud/test', 'audio',
|
|
792
|
+
'direct', 'direct/context', 'direct/session', 'voice-plan'],
|
|
605
793
|
})
|
|
606
794
|
} catch (e) {
|
|
607
795
|
sendJson(res, 500, { ok: false, error: e?.message ?? String(e) })
|
package/lib/breeze.mjs
CHANGED
|
@@ -188,6 +188,12 @@ function normalizeAudio(o, src, dst) {
|
|
|
188
188
|
})
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/** 把任意音频规整成 24k 单声道 wav(给 --ref-audio 直接用,不编码成 .breeze)。 */
|
|
192
|
+
export async function normalizeRef(o, src, dst) {
|
|
193
|
+
ensureDirs(o)
|
|
194
|
+
return normalizeAudio(o, src, dst)
|
|
195
|
+
}
|
|
196
|
+
|
|
191
197
|
/** 解析「用户给的音频」:收件箱文件名、或存在的绝对路径;不许目录穿越。 */
|
|
192
198
|
export function resolveAudioInput(o, audio) {
|
|
193
199
|
const raw = String(audio ?? '').trim()
|
package/lib/director.mjs
CHANGED
|
@@ -13,10 +13,13 @@ import fs from 'node:fs'
|
|
|
13
13
|
import os from 'node:os'
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
|
|
16
|
+
import { askSession, dshOptions, probeDsh } from './dshsession.mjs'
|
|
17
|
+
|
|
16
18
|
const DEFAULT_BASE_URL = 'https://ai.xyclub.top/v1'
|
|
17
19
|
const DEFAULT_MODEL = 'deepseek-v4.1-flash'
|
|
18
20
|
const DEFAULT_API_KEY_ENV = 'DPSK_API_KEY'
|
|
19
21
|
|
|
22
|
+
|
|
20
23
|
const LIFE_CANDIDATES = ['D:/dprz/life', 'D:/dprz', path.join(os.homedir(), '.dsh-data', 'life')]
|
|
21
24
|
const EMOTION_CANDIDATES = ['D:/dprz/qg', path.join(os.homedir(), '.dsh-data', 'emotion')]
|
|
22
25
|
const BRIDGE_CANDIDATES = [
|
|
@@ -64,8 +67,10 @@ function pickDir(explicit, envName, candidates) {
|
|
|
64
67
|
export function directorOptions(cfg = {}) {
|
|
65
68
|
const d = cfg.director && typeof cfg.director === 'object' ? cfg.director : {}
|
|
66
69
|
const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb)
|
|
70
|
+
const transport = String(d.transport || 'dsh').trim() === 'api' ? 'api' : 'dsh'
|
|
67
71
|
return {
|
|
68
72
|
enabled: d.enabled !== false,
|
|
73
|
+
transport,
|
|
69
74
|
baseUrl: String(d.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
70
75
|
model: String(d.model || DEFAULT_MODEL),
|
|
71
76
|
apiKey: String(d.apiKey || '').trim(),
|
|
@@ -78,6 +83,8 @@ export function directorOptions(cfg = {}) {
|
|
|
78
83
|
lifeHome: pickDir(cfg.lifeHome, 'DSH_LIFE_HOME', LIFE_CANDIDATES),
|
|
79
84
|
emotionHome: pickDir(cfg.emotionHome, 'DSH_QG_HOME', EMOTION_CANDIDATES),
|
|
80
85
|
bridgeState: pickDir(cfg.bridgeState, 'DSH_BRIDGE_STATE', BRIDGE_CANDIDATES),
|
|
86
|
+
// 走 DSH 会话时的参数(会话是「在 DeepSeek 窗口里能看见的那个导演台」)
|
|
87
|
+
dsh: dshOptions(cfg),
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -106,9 +113,13 @@ export function resolveApiKey(o) {
|
|
|
106
113
|
export function probeDirector(cfg = {}) {
|
|
107
114
|
const o = directorOptions(cfg)
|
|
108
115
|
const { key, from } = resolveApiKey(o)
|
|
116
|
+
const dsh = probeDsh(cfg)
|
|
117
|
+
const viaDsh = o.transport === 'dsh'
|
|
109
118
|
return {
|
|
110
119
|
enabled: o.enabled,
|
|
111
|
-
|
|
120
|
+
// 走会话时,只要能连上 DSH 就算就绪(不需要 key —— 用宿主自己的凭据)
|
|
121
|
+
ready: Boolean(o.enabled && (viaDsh ? dsh.ready : Boolean(key))),
|
|
122
|
+
transport: o.transport,
|
|
112
123
|
baseUrl: o.baseUrl,
|
|
113
124
|
model: o.model,
|
|
114
125
|
hasKey: Boolean(key),
|
|
@@ -119,6 +130,7 @@ export function probeDirector(cfg = {}) {
|
|
|
119
130
|
lifeHome: o.lifeHome,
|
|
120
131
|
emotionHome: o.emotionHome,
|
|
121
132
|
bridgeState: o.bridgeState,
|
|
133
|
+
dsh,
|
|
122
134
|
}
|
|
123
135
|
}
|
|
124
136
|
|
|
@@ -425,6 +437,31 @@ async function callModel(o, key, messages, extraNote) {
|
|
|
425
437
|
return { content, elapsedMs: Date.now() - started, usage: json?.usage ?? null }
|
|
426
438
|
}
|
|
427
439
|
|
|
440
|
+
/** 会话里没有独立的 system 角色,把规则与请求压成一段文字发进去。 */
|
|
441
|
+
function messagesToText(messages) {
|
|
442
|
+
return messages
|
|
443
|
+
.map((m) => (m.role === 'system' ? `【规则】\n${m.content}` : `【这一句】\n${m.content}`))
|
|
444
|
+
.join('\n\n')
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 问「同一个大脑」,两条通道:
|
|
449
|
+
* - dsh:在 DSH 里那个专用会话里问(GUI 里看得见,用宿主自己的模型与凭据);
|
|
450
|
+
* - api:直连 OpenAI 兼容接口(老路,作为会话不可用时的兜底)。
|
|
451
|
+
*/
|
|
452
|
+
async function askModel(o, key, messages, extraNote, ctx = {}) {
|
|
453
|
+
const full = extraNote ? [...messages, { role: 'user', content: extraNote }] : messages
|
|
454
|
+
if (o.transport === 'dsh') {
|
|
455
|
+
const dsh = o.dsh
|
|
456
|
+
if (!dsh.ready) throw new Error(dsh.baseUrl ? 'DSH 会话通道不可用(读不到会话密钥)' : '找不到 DSH 地址(桥接没记下 baseUrl,可在设置里填 dshBaseUrl)')
|
|
457
|
+
const r = await askSession(dsh, { text: messagesToText(full), hooks: ctx.hooks })
|
|
458
|
+
if (!r.ok) throw new Error(r.error || 'DSH 会话没回话')
|
|
459
|
+
return { content: r.text, elapsedMs: r.elapsedMs, transport: 'dsh', sessionId: r.sessionId }
|
|
460
|
+
}
|
|
461
|
+
const out = await callModel(o, key, full)
|
|
462
|
+
return { ...out, transport: 'api', sessionId: '' }
|
|
463
|
+
}
|
|
464
|
+
|
|
428
465
|
/**
|
|
429
466
|
* 真正的入口:把台词 + 人设 + 情绪 + 场景 交给同一个大脑,拿回打好标点的台词。
|
|
430
467
|
* 任何一步失败都不抛错 —— 合成不能因为「导演」挂掉就发不出声。
|
|
@@ -449,8 +486,15 @@ export async function direct(cfg = {}, input = {}) {
|
|
|
449
486
|
if (!original) { result.reason = '没有文字'; return result }
|
|
450
487
|
|
|
451
488
|
const { key, from } = resolveApiKey(o)
|
|
452
|
-
|
|
453
|
-
|
|
489
|
+
const viaDsh = o.transport === 'dsh'
|
|
490
|
+
if (viaDsh ? !o.dsh.ready : !key) {
|
|
491
|
+
result.reason = viaDsh
|
|
492
|
+
? 'DSH 会话通道没就绪(找不到 DSH 地址或会话密钥)'
|
|
493
|
+
: `没找到模型 key(可用 ${o.apiKeyEnv} 环境变量或设置里填 apiKey)`
|
|
494
|
+
return result
|
|
495
|
+
}
|
|
496
|
+
result.keyFrom = viaDsh ? 'dsh-session' : from
|
|
497
|
+
result.transport = o.transport
|
|
454
498
|
|
|
455
499
|
const identity = readIdentity(o)
|
|
456
500
|
const emotion = readEmotion(o, input.scope || o.scope)
|
|
@@ -479,21 +523,28 @@ export async function direct(cfg = {}, input = {}) {
|
|
|
479
523
|
if (cached) return { ...cached, cached: true }
|
|
480
524
|
|
|
481
525
|
try {
|
|
482
|
-
let out = await
|
|
526
|
+
let out = await askModel(o, key, messages, '', { hooks: input.hooks })
|
|
483
527
|
let parsed = parseReply(out.content)
|
|
484
528
|
result.elapsedMs = out.elapsedMs
|
|
529
|
+
if (out.transport) result.transport = out.transport
|
|
530
|
+
if (out.sessionId) {
|
|
531
|
+
result.sessionId = out.sessionId
|
|
532
|
+
o.dsh.sessionId = out.sessionId // 重试沿用同一个窗口,别又开一个
|
|
533
|
+
}
|
|
485
534
|
if (out.usage) result.usage = out.usage
|
|
486
535
|
|
|
487
536
|
if (!parsed.render || !sameWords(original, parsed.render)) {
|
|
488
537
|
// 改动字词了 —— 再给它一次机会,只说这一件事
|
|
489
|
-
out = await
|
|
538
|
+
out = await askModel(
|
|
490
539
|
o,
|
|
491
540
|
key,
|
|
492
541
|
messages,
|
|
493
542
|
'注意:你上一次的输出改动了字词,作废重做。这次只许插入或替换标点符号,汉字和数字一个都不许变,不许增删。',
|
|
543
|
+
{ hooks: input.hooks },
|
|
494
544
|
)
|
|
495
545
|
parsed = parseReply(out.content)
|
|
496
546
|
result.elapsedMs = out.elapsedMs
|
|
547
|
+
if (out.sessionId) result.sessionId = out.sessionId
|
|
497
548
|
if (out.usage) result.usage = out.usage
|
|
498
549
|
}
|
|
499
550
|
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 让「读音导演」跑在 DSH 自己的会话里 —— 而不是插件直连模型 API。
|
|
3
|
+
*
|
|
4
|
+
* 为什么这么做:用户要的是「在 DeepSeek 窗口里专门开一个会话给导演台用」。
|
|
5
|
+
* 走 DSH 会话有三个额外好处:
|
|
6
|
+
* 1. 导演的每一次思考都能在 GUI 里看见(可回溯、可干预);
|
|
7
|
+
* 2. 用的是宿主自己的 provider / 模型 / 凭据,插件不碰任何 key;
|
|
8
|
+
* 3. 会话自带上下文与压缩,插件不必自己维护历史。
|
|
9
|
+
*
|
|
10
|
+
* 实现只用到两个东西:
|
|
11
|
+
* - 本机 DSH 的浏览器会话 Cookie(用 ~/.dsh/.credentials.yaml 里的签名密钥现算,不需要启动 token);
|
|
12
|
+
* - 三个 RPC:session/create、session/prompt、session/page。
|
|
13
|
+
*
|
|
14
|
+
* 注意:这里刻意**不走 WebSocket 事件流**。session/page 一次就能读到最新一段日志,
|
|
15
|
+
* 轮询比维护一条 mux 长连接简单得多,也不怕 DSH 重启断流。
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 60000
|
|
23
|
+
const DEFAULT_POLL_MS = 700
|
|
24
|
+
const DEFAULT_TITLE = '读音导演 · dsh-voice-say'
|
|
25
|
+
const SESSION_TTL_MS = 3600 * 1000
|
|
26
|
+
|
|
27
|
+
const BRIDGE_CONFIG_CANDIDATES = [
|
|
28
|
+
'C:/Users/admin/Documents/deepseek-harness/default-workspace/qq-bridge-main/config.json',
|
|
29
|
+
path.join(process.cwd(), 'qq-bridge-main', 'config.json'),
|
|
30
|
+
path.join(os.homedir(), 'Documents', 'deepseek-harness', 'default-workspace', 'qq-bridge-main', 'config.json'),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const WORKSPACE_CANDIDATES = [
|
|
34
|
+
'C:/Users/admin/Documents/deepseek-harness/default-workspace',
|
|
35
|
+
path.join(os.homedir(), 'Documents', 'deepseek-harness', 'default-workspace'),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/* ───────────────────── 配置 ───────────────────── */
|
|
39
|
+
|
|
40
|
+
function readJsonSafe(file, fallback) {
|
|
41
|
+
try {
|
|
42
|
+
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
43
|
+
return JSON.parse(text)
|
|
44
|
+
} catch {
|
|
45
|
+
return fallback
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function firstExisting(candidates) {
|
|
50
|
+
for (const c of candidates) {
|
|
51
|
+
try {
|
|
52
|
+
if (c && fs.existsSync(c)) return c
|
|
53
|
+
} catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** DSH 的地址会随重启变化,所以顺序是:显式配置 → 环境变量 → QQ 桥接里记着的那个。 */
|
|
59
|
+
export function discoverDshBase(cfg = {}) {
|
|
60
|
+
const d = cfg.director && typeof cfg.director === 'object' ? cfg.director : {}
|
|
61
|
+
const explicit = String(d.dshBaseUrl || process.env.DSH_BASE_URL || '').trim()
|
|
62
|
+
if (explicit) return { baseUrl: explicit.replace(/\/+$/, ''), from: 'config' }
|
|
63
|
+
for (const file of BRIDGE_CONFIG_CANDIDATES) {
|
|
64
|
+
const j = readJsonSafe(file, null)
|
|
65
|
+
const url = String(j?.dsh?.baseUrl || '').trim()
|
|
66
|
+
if (url) return { baseUrl: url.replace(/\/+$/, ''), from: 'bridge-config' }
|
|
67
|
+
}
|
|
68
|
+
return { baseUrl: '', from: '' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function dshOptions(cfg = {}) {
|
|
72
|
+
const d = cfg.director && typeof cfg.director === 'object' ? cfg.director : {}
|
|
73
|
+
const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb)
|
|
74
|
+
const { baseUrl, from } = discoverDshBase(cfg)
|
|
75
|
+
const enabled = d.enabled !== false && d.transport !== 'api'
|
|
76
|
+
const hasSecret = Boolean(readSigningSecret())
|
|
77
|
+
return {
|
|
78
|
+
enabled,
|
|
79
|
+
// 能连上 DSH 的三个条件:通道开着、地址知道、会话密钥读得到
|
|
80
|
+
ready: Boolean(enabled && baseUrl && hasSecret),
|
|
81
|
+
hasSecret,
|
|
82
|
+
baseUrl,
|
|
83
|
+
baseFrom: from,
|
|
84
|
+
title: String(d.sessionTitle || DEFAULT_TITLE),
|
|
85
|
+
cwd: String(d.sessionCwd || '').trim() || firstExisting(WORKSPACE_CANDIDATES) || process.cwd(),
|
|
86
|
+
timeoutMs: Math.min(Math.max(num(d.sessionTimeoutMs, DEFAULT_TIMEOUT_MS), 5000), 180000),
|
|
87
|
+
pollMs: Math.min(Math.max(num(d.sessionPollMs, DEFAULT_POLL_MS), 250), 5000),
|
|
88
|
+
sessionId: String(d.sessionId || '').trim(),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ───────────────────── Cookie ───────────────────── */
|
|
93
|
+
|
|
94
|
+
function credentialsFile() {
|
|
95
|
+
const home = String(process.env.DSH_HOME || '').trim() || path.join(os.homedir(), '.dsh')
|
|
96
|
+
return path.join(home, '.credentials.yaml')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const b64url = (buf) => buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
|
100
|
+
|
|
101
|
+
/** 读 DSH 写下的浏览器会话签名密钥(32 字节)。只读,不落盘。 */
|
|
102
|
+
export function readSigningSecret() {
|
|
103
|
+
try {
|
|
104
|
+
const text = fs.readFileSync(credentialsFile(), 'utf8')
|
|
105
|
+
const m = text.match(/client-connection\/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{40,})/)
|
|
106
|
+
if (!m) return null
|
|
107
|
+
const buf = Buffer.from(m[1].replaceAll('-', '+').replaceAll('_', '/'), 'base64')
|
|
108
|
+
return buf.byteLength === 32 ? buf : null
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 为一个 DSH 地址现场签一个会话 Cookie(`name=value`)。 */
|
|
115
|
+
export function mintCookie(baseUrl, secret) {
|
|
116
|
+
const authority = new URL(String(baseUrl)).host
|
|
117
|
+
if (!authority) throw new Error('DSH 地址里没有 authority')
|
|
118
|
+
const issuedAt = Date.now()
|
|
119
|
+
const body = b64url(Buffer.from(JSON.stringify({
|
|
120
|
+
version: 1, authority, issuedAt, expiresAt: issuedAt + SESSION_TTL_MS,
|
|
121
|
+
}), 'utf8'))
|
|
122
|
+
const signature = b64url(createHmac('sha256', secret).update(body).digest())
|
|
123
|
+
const name = 'dsh-auth-' + b64url(createHash('sha256').update(authority).digest())
|
|
124
|
+
return `${name}=v1.${body}.${signature}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ───────────────────── RPC ───────────────────── */
|
|
128
|
+
|
|
129
|
+
async function rpc(o, method, request, signal) {
|
|
130
|
+
if (!o.baseUrl) throw new Error('不知道 DSH 地址(桥接没记 baseUrl?可以在设置里填 director.dshBaseUrl)')
|
|
131
|
+
const secret = readSigningSecret()
|
|
132
|
+
if (!secret) throw new Error('读不到 DSH 的浏览器会话密钥(~/.dsh/.credentials.yaml)')
|
|
133
|
+
const cookie = mintCookie(o.baseUrl, secret)
|
|
134
|
+
const res = await fetch(`${o.baseUrl}/api/${method}`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'content-type': 'application/json', cookie },
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
type: 'client-request',
|
|
139
|
+
rpcId: `voice-say-${randomUUID()}`,
|
|
140
|
+
method,
|
|
141
|
+
payload: { args: { request } },
|
|
142
|
+
}),
|
|
143
|
+
signal,
|
|
144
|
+
})
|
|
145
|
+
const json = await res.json().catch(() => null)
|
|
146
|
+
const result = json?.result
|
|
147
|
+
if (!result) throw new Error(`${method}: ${res.status} 无有效响应`)
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
const err = new Error(`${method}: ${result.error?.code || 'unknown'}: ${result.error?.message || ''}`)
|
|
150
|
+
err.code = result.error?.code
|
|
151
|
+
throw err
|
|
152
|
+
}
|
|
153
|
+
return result.value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 当前日志游标。
|
|
158
|
+
* DSH 不给「查游标」的接口,但 session/page 越界时会把真游标写在错误里
|
|
159
|
+
* (`through seq 999999999 is past cursor 19`),拿它当探针最省事。
|
|
160
|
+
*/
|
|
161
|
+
async function cursorOf(o, sessionId, signal) {
|
|
162
|
+
try {
|
|
163
|
+
await rpc(o, 'session/page', { address: { kind: 'session', sessionId }, throughSeq: 999999999, maxMessages: 1 }, signal)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const m = String(error?.message || '').match(/past cursor (\d+)/)
|
|
166
|
+
if (m) return Number(m[1])
|
|
167
|
+
throw error
|
|
168
|
+
}
|
|
169
|
+
// 极端情况:日志真的是空的,游标就是 0
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 会话还在不在。 */
|
|
174
|
+
export async function sessionAlive(o, sessionId, signal) {
|
|
175
|
+
if (!sessionId) return false
|
|
176
|
+
try {
|
|
177
|
+
await cursorOf(o, sessionId, signal)
|
|
178
|
+
return true
|
|
179
|
+
} catch {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 建会话。 */
|
|
185
|
+
export async function createSession(o, signal) {
|
|
186
|
+
const value = await rpc(o, 'session/create', { cwd: o.cwd, title: o.title }, signal)
|
|
187
|
+
const sessionId = String(value?.sessionId || '')
|
|
188
|
+
if (!sessionId) throw new Error('session/create 没返回 sessionId')
|
|
189
|
+
return sessionId
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 取一个可用会话:配置里那个还活着就复用,否则新建。 */
|
|
193
|
+
export async function ensureSession(o, signal, hooks = {}) {
|
|
194
|
+
if (o.sessionId && (await sessionAlive(o, o.sessionId, signal))) {
|
|
195
|
+
return { ok: true, sessionId: o.sessionId, created: false }
|
|
196
|
+
}
|
|
197
|
+
const sessionId = await createSession(o, signal)
|
|
198
|
+
if (typeof hooks.onSession === 'function') {
|
|
199
|
+
try { await hooks.onSession(sessionId) } catch { /* 存不下去也不影响这一次 */ }
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, sessionId, created: true }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ───────────────────── 一问一答 ───────────────────── */
|
|
205
|
+
|
|
206
|
+
function textOfAssistant(event) {
|
|
207
|
+
const content = event?.data?.message?.content
|
|
208
|
+
if (!Array.isArray(content)) return ''
|
|
209
|
+
return content
|
|
210
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
211
|
+
.map((b) => b.text)
|
|
212
|
+
.join('')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 在 DSH 会话里问一句,等它把这一轮说完。
|
|
217
|
+
* @returns {{ok:boolean, sessionId:string, text:string, elapsedMs:number, turns:number, error?:string}}
|
|
218
|
+
*/
|
|
219
|
+
export async function askSession(o, { text, sessionId, signal, hooks = {} } = {}) {
|
|
220
|
+
const started = Date.now()
|
|
221
|
+
const deadline = started + o.timeoutMs
|
|
222
|
+
let sid = sessionId || ''
|
|
223
|
+
try {
|
|
224
|
+
if (!sid) {
|
|
225
|
+
const ensured = await ensureSession(o, signal, hooks)
|
|
226
|
+
sid = ensured.sessionId
|
|
227
|
+
}
|
|
228
|
+
const before = await cursorOf(o, sid, signal)
|
|
229
|
+
await rpc(o, 'session/prompt', {
|
|
230
|
+
requestId: `voice-say-${randomUUID()}`,
|
|
231
|
+
sessionId: sid,
|
|
232
|
+
mode: 'queue',
|
|
233
|
+
content: [{ type: 'text', text }],
|
|
234
|
+
}, signal)
|
|
235
|
+
|
|
236
|
+
let turns = 0
|
|
237
|
+
while (Date.now() < deadline) {
|
|
238
|
+
await new Promise((r) => setTimeout(r, o.pollMs))
|
|
239
|
+
if (signal?.aborted) throw new Error('aborted')
|
|
240
|
+
let cur = 0
|
|
241
|
+
try {
|
|
242
|
+
cur = await cursorOf(o, sid, signal)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (/past cursor/.test(String(error?.message))) continue
|
|
245
|
+
throw error
|
|
246
|
+
}
|
|
247
|
+
if (cur <= before) continue
|
|
248
|
+
const page = await rpc(o, 'session/page', {
|
|
249
|
+
address: { kind: 'session', sessionId: sid }, throughSeq: cur, maxMessages: 30,
|
|
250
|
+
}, signal)
|
|
251
|
+
const events = (page?.records || []).map((r) => r?.event).filter(Boolean)
|
|
252
|
+
const finished = new Set(
|
|
253
|
+
events.filter((e) => e.type === 'turn/end').map((e) => e.data?.turn),
|
|
254
|
+
)
|
|
255
|
+
const mine = events
|
|
256
|
+
.filter((e) => e.type === 'assistant/message' && Number(e.seq) > before)
|
|
257
|
+
.filter((e) => finished.has(e.data?.turn))
|
|
258
|
+
turns = Math.max(turns, mine.length)
|
|
259
|
+
if (mine.length) {
|
|
260
|
+
const last = mine[mine.length - 1]
|
|
261
|
+
const answer = textOfAssistant(last).trim()
|
|
262
|
+
if (answer) {
|
|
263
|
+
return { ok: true, sessionId: sid, text: answer, elapsedMs: Date.now() - started, turns }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { ok: false, sessionId: sid, text: '', elapsedMs: Date.now() - started, turns, error: 'timeout:会话里这一轮没在超时前说完' }
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false, sessionId: sid, text: '', elapsedMs: Date.now() - started,
|
|
271
|
+
turns: 0, error: String(error?.message || error),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 给状态面板看的摘要(不发请求,只看能不能用)。 */
|
|
277
|
+
export function probeDsh(cfg = {}) {
|
|
278
|
+
const o = dshOptions(cfg)
|
|
279
|
+
return {
|
|
280
|
+
enabled: o.enabled,
|
|
281
|
+
ready: o.ready,
|
|
282
|
+
baseUrl: o.baseUrl,
|
|
283
|
+
baseFrom: o.baseFrom,
|
|
284
|
+
hasSecret: o.hasSecret,
|
|
285
|
+
sessionId: o.sessionId,
|
|
286
|
+
title: o.title,
|
|
287
|
+
cwd: o.cwd,
|
|
288
|
+
timeoutMs: o.timeoutMs,
|
|
289
|
+
}
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-voice-say",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "dsh-voice-say:把一句话合成语音。两条通道(本地 Breeze TTS 2 / 云端 apiKey+voiceId)共用 instructions + guidanceScale;18 个风格可叠加;合成前由「读音导演」重排标点——默认在 DeepSeek 窗口里的专用会话里想,按人设、当下情绪与对话情节断句;支持上传本地音色、自定义参考音频与试听片段。QQ 桥接会先问插件,群里发出去的语音与设置页试听同源同风格。",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "dsh-voice-say contributors",
|
|
8
8
|
"keywords": [
|