dsh-voice-say 0.1.1 → 0.5.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 +33 -0
- package/client.js +84 -1
- package/index.js +156 -6
- package/lib/breeze.mjs +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -96,6 +96,34 @@ voice_direct { text: "你不要再这样了,我真的会难过的。", styles:
|
|
|
96
96
|
→ 情绪 哽咽压抑
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## QQ 里的声音 = 设置页试听(0.5.0)
|
|
100
|
+
|
|
101
|
+
以前群里发出去的语音和这里试听**不是一个嗓子**,三个原因叠在一起:
|
|
102
|
+
|
|
103
|
+
1. **音源不同**:QQ 那边桥接固定用自己那份参考音频(`audio/大肥鱼.mp3` 裁 12 秒)克隆;
|
|
104
|
+
而设置页如果没选音色,`--voice` / `--ref-audio` 都不传,引擎走的是**内置默认说话人**。
|
|
105
|
+
2. **参数不同**:试听带着 `--instruction` + `--cfg-scale`,QQ 那条 `cfgScale: 1` 什么都不传。
|
|
106
|
+
3. **最后一道**:QQ 语音条要过 SILK 编码(网关做的),低码率会削高频、声音发闷——这条躲不掉,谁发都一样。
|
|
107
|
+
|
|
108
|
+
现在反过来:**QQ 那边先问插件**。桥接每次要发语音,先 `POST /api/voice-say/voice-plan` 拿一份「这句怎么念」:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
台词 ─┐
|
|
112
|
+
├─ 读音导演 → 打好标点的台词(spoken)
|
|
113
|
+
├─ 风格 → instructions + guidanceScale
|
|
114
|
+
└─ 音色 → voice(默认音色)或 refAudio(自定义参考音频)
|
|
115
|
+
↓
|
|
116
|
+
Breeze / 云端 → 音频 → SILK → QQ
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
于是三样东西对齐了:**标点、语气、嗓子**。想让它彻底一致,只要在设置页做一件事:
|
|
120
|
+
|
|
121
|
+
- 「自定义参考音频」传一段音频 → 存成 `<数据根>/ref/ref-24k.wav`,QQ 发语音就用它当 `--ref-audio`;
|
|
122
|
+
- 或者上传成音色再「设为默认」(音色同样是从你那段音频编出来的)。
|
|
123
|
+
|
|
124
|
+
两样都没设,才回落到桥接自带的克隆。桥接侧开关:`socialV2.voice.followPlugin`(`false` = 临时不跟插件)、
|
|
125
|
+
`socialV2.voice.pluginBaseUrl`(默认取 `dsh.baseUrl`)。桥接失败/超时一律 null 回落老路,不会把消息卡死。
|
|
126
|
+
|
|
99
127
|
## 装
|
|
100
128
|
|
|
101
129
|
```bash
|
|
@@ -145,6 +173,8 @@ dsh plugin --profile <profile> add dsh-voice-say
|
|
|
145
173
|
| `seed` | `42` | 换种子 = 换一种演绎 |
|
|
146
174
|
| `maxChars` | `300` | 单次合成字数上限 |
|
|
147
175
|
| `defaultVoice` | 空 | 不指定就用 `instructions` 现场设计嗓音 |
|
|
176
|
+
| `refAudio` | 空 | 自定义参考音频的 24k wav 路径(界面「就用这段当嗓子」会写它);没音色时用它克隆 |
|
|
177
|
+
| `refText` | 空 | 上面那段音频的逐字转写 |
|
|
148
178
|
| `cli` / `model` | 自动探测 | breeze-cli.exe 与 .gguf |
|
|
149
179
|
| `voicesDir` / `inboxDir` | 自动探测 | 音色目录 / 上传暂存目录 |
|
|
150
180
|
| `device` / `gpuIndex` | `vulkan` / `1` | 多显卡机器要钉住独显 |
|
|
@@ -176,6 +206,9 @@ dsh plugin --profile <profile> add dsh-voice-say
|
|
|
176
206
|
| GET | `/api/voice-say/voices` | 列音色 |
|
|
177
207
|
| POST | `/api/voice-say/voices` | 上传音色:`{name,text,description,filename,data}`(`data` 为 base64)或 `{name,text,src}` |
|
|
178
208
|
| POST | `/api/voice-say/voices/delete` | `{name}` |
|
|
209
|
+
| POST | `/api/voice-say/voices/default` | `{name}` 设成默认音色(空串=取消),QQ 发语音也用它 |
|
|
210
|
+
| POST | `/api/voice-say/ref` | 自定义参考音频:`{filename,data}`(base64)或 `{src,text}`;`{clear:true}` 清掉 |
|
|
211
|
+
| POST | `/api/voice-say/voice-plan` | 只出「这句怎么念」:`{text,styles,instructions,scope,direct}` → `spoken/instructions/guidanceScale/voice/refAudio` |
|
|
179
212
|
| POST | `/api/voice-say/synth` | 合成:`{text,provider,voice,styles,instructions,guidanceScale,seed,direct,scope}` |
|
|
180
213
|
| POST | `/api/voice-say/preview` | 试听(同上,`text` 可省) |
|
|
181
214
|
| POST | `/api/voice-say/cloud/test` | 云端连通性(真发一次极短合成) |
|
package/client.js
CHANGED
|
@@ -129,6 +129,7 @@ window.__ModuleLoader__.load({
|
|
|
129
129
|
const [directCtx, setDirectCtx] = useState(null)
|
|
130
130
|
const [voices, setVoices] = useState([])
|
|
131
131
|
const [up, setUp] = useState({ name: '', text: '', description: '', file: null })
|
|
132
|
+
const [refUp, setRefUp] = useState({ text: '', file: null })
|
|
132
133
|
const [text, setText] = useState('哼,才不是特意来跟你说话的呢,笨蛋')
|
|
133
134
|
const [busy, setBusy] = useState('')
|
|
134
135
|
const [err, setErr] = useState('')
|
|
@@ -207,6 +208,30 @@ window.__ModuleLoader__.load({
|
|
|
207
208
|
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
/** 上传一段音频当「参考音频」用:不编成音色,直接拿它克隆。
|
|
212
|
+
* QQ 发语音时桥接会问插件,拿到这个文件当 --ref-audio —— 所以群里那个嗓子就是你上传的这段。 */
|
|
213
|
+
const uploadRef = async () => {
|
|
214
|
+
setBusy('ref'); setErr(''); setNotice('')
|
|
215
|
+
try {
|
|
216
|
+
if (!refUp.file) throw new Error('先选一个音频文件(wav/mp3/m4a/flac/ogg,5~15 秒最稳)')
|
|
217
|
+
if (refUp.file.size > 20 * 1024 * 1024) throw new Error('文件超过 20MB,换短一点的一段')
|
|
218
|
+
const data = await readFileBase64(refUp.file)
|
|
219
|
+
const r = await jpost('/ref', { text: refUp.text.trim(), filename: refUp.file.name, data })
|
|
220
|
+
if (r?.status) mergeStatus(r.status)
|
|
221
|
+
setNotice(r?.warning || '参考音频已设好:试听和 QQ 发出去的都是这一段的声音')
|
|
222
|
+
setRefUp({ text: '', file: null })
|
|
223
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const clearRef = async () => {
|
|
227
|
+
setBusy('ref'); setErr(''); setNotice('')
|
|
228
|
+
try {
|
|
229
|
+
const r = await jpost('/ref', { clear: true })
|
|
230
|
+
if (r?.status) mergeStatus(r.status)
|
|
231
|
+
setNotice('已清除参考音频(回到引擎默认 / 桥接自带的参考音频)')
|
|
232
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
233
|
+
}
|
|
234
|
+
|
|
210
235
|
const removeVoice = async (name) => {
|
|
211
236
|
setBusy('del'); setErr(''); setNotice('')
|
|
212
237
|
try {
|
|
@@ -216,6 +241,17 @@ window.__ModuleLoader__.load({
|
|
|
216
241
|
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
217
242
|
}
|
|
218
243
|
|
|
244
|
+
/** 设为默认音色:写进插件配置。QQ 发语音时桥接会先问插件,所以这里设了,群里也是这个嗓子。 */
|
|
245
|
+
const setDefaultVoice = async (name) => {
|
|
246
|
+
setBusy('def'); setErr(''); setNotice('')
|
|
247
|
+
try {
|
|
248
|
+
const r = await jpost('/voices/default', { name })
|
|
249
|
+
setForm((f) => ({ ...f, defaultVoice: name }))
|
|
250
|
+
if (r?.status) mergeStatus(r.status)
|
|
251
|
+
setNotice(name ? `默认音色 → ${name}(QQ 发语音也会用它)` : '已取消默认音色(回到参考音频克隆)')
|
|
252
|
+
} catch (e) { setErr(e?.message || String(e)) } finally { setBusy('') }
|
|
253
|
+
}
|
|
254
|
+
|
|
219
255
|
const styleById = (id) => styleList.find((s) => s.id === id)
|
|
220
256
|
|
|
221
257
|
/** 多选风格:选上就把它建议的强度顶上去(用户仍可手动改)。 */
|
|
@@ -569,10 +605,50 @@ window.__ModuleLoader__.load({
|
|
|
569
605
|
),
|
|
570
606
|
),
|
|
571
607
|
|
|
608
|
+
// ── 自定义参考音频(不保存成音色) ──
|
|
609
|
+
h('div', { 'data-vs-card': '' },
|
|
610
|
+
h('div', { 'data-vs-h': '' }, '自定义参考音频(直接当嗓子用,不编成音色)'),
|
|
611
|
+
h('div', { 'data-vs-b': '' },
|
|
612
|
+
h('div', { 'data-vs-sub': '' },
|
|
613
|
+
'这里传一段音频,插件就把它存成 `<数据根>/ref/ref-24k.wav`,'
|
|
614
|
+
+ '**试听和 QQ 群里发出去的语音都用它当参考**(桥接会先问插件要这个文件)——'
|
|
615
|
+
+ '所以「QQ 里的声音」= 你上传的这段音频的音色。'),
|
|
616
|
+
st?.reference?.exists
|
|
617
|
+
? h('div', { 'data-vs-kv': '' },
|
|
618
|
+
h('span', { 'data-vs-k': '' }, '当前参考音频'),
|
|
619
|
+
h('span', { 'data-vs-mono': '', style: { fontSize: 11 } }, st.reference.refAudio),
|
|
620
|
+
h('span', { 'data-vs-sub': '' },
|
|
621
|
+
st.reference.used ? '生效中(没有默认音色,就用它)' : '已存,但当前用的是默认音色'),
|
|
622
|
+
h('button', { 'data-vs-btn': '', disabled: busy === 'ref', onClick: clearRef }, '清除'))
|
|
623
|
+
: h('div', { 'data-vs-sub': '' }, '还没设置参考音频。'),
|
|
624
|
+
h('div', { 'data-vs-kv': '' },
|
|
625
|
+
h('span', { 'data-vs-k': '' }, '逐字转写(这段音频里一字不差说的话,强烈建议给)'),
|
|
626
|
+
h('textarea', {
|
|
627
|
+
'data-vs-area': '', value: refUp.text,
|
|
628
|
+
placeholder: '把这段音频里说的话原样打一遍;不给的话引擎得靠猜,音色会不稳。',
|
|
629
|
+
onChange: (e) => setRefUp({ ...refUp, text: e.target.value }),
|
|
630
|
+
}),
|
|
631
|
+
),
|
|
632
|
+
h('div', { 'data-vs-row': '' },
|
|
633
|
+
h('input', {
|
|
634
|
+
type: 'file', accept: 'audio/*', style: { fontSize: 11 },
|
|
635
|
+
onChange: (e) => setRefUp({ ...refUp, file: e.target.files?.[0] || null }),
|
|
636
|
+
}),
|
|
637
|
+
h('span', { 'data-vs-sub': '' }, refUp.file ? `${refUp.file.name} · ${Math.round(refUp.file.size / 1024)} KB` : '5~15 秒、安静环境、单人说话'),
|
|
638
|
+
h('button', { 'data-vs-btn': '', 'data-vs-primary': '', disabled: busy === 'ref', onClick: uploadRef },
|
|
639
|
+
busy === 'ref' ? '处理中…' : '就用这段当嗓子'),
|
|
640
|
+
),
|
|
641
|
+
h('div', { 'data-vs-sub': '' }, '非 wav 会先用 ffmpeg 转 24k 单声道,只取前 15 秒。'),
|
|
642
|
+
),
|
|
643
|
+
),
|
|
644
|
+
|
|
572
645
|
// ── 音色列表 ──
|
|
573
646
|
h('div', { 'data-vs-card': '' },
|
|
574
647
|
h('div', { 'data-vs-h': '' }, `已保存音色(${voices.length})`),
|
|
575
648
|
h('div', { 'data-vs-b': '' },
|
|
649
|
+
h('div', { 'data-vs-sub': '' },
|
|
650
|
+
'「设为默认」会写进插件配置:**QQ 群里发语音时,桥接先问插件,嗓子、语气、标点都照这里来**。'
|
|
651
|
+
+ '不设默认时用上面的「自定义参考音频」;两样都没有,QQ 才回落到桥接自带的 `audio/大肥鱼.mp3`。'),
|
|
576
652
|
voices.length === 0
|
|
577
653
|
? h('div', { 'data-vs-sub': '' }, '还没有音色。上传一段参考音频,或者干脆不给音色、只写 instructions 让模型现编一个。')
|
|
578
654
|
: voices.map((v) => h('div', {
|
|
@@ -581,10 +657,17 @@ window.__ModuleLoader__.load({
|
|
|
581
657
|
h('span', { 'data-vs-mono': '', style: { minWidth: 110 } }, v.name),
|
|
582
658
|
h('span', { 'data-vs-sub': '' }, `${v.seconds ?? '?'}s`),
|
|
583
659
|
h('span', { 'data-vs-sub': '', style: { flex: 1 } }, v.description || v.refText || ''),
|
|
660
|
+
form.defaultVoice === v.name
|
|
661
|
+
? h('span', { 'data-vs-sub': '', style: { color: '#5b8def' } }, '默认')
|
|
662
|
+
: null,
|
|
663
|
+
h('button', {
|
|
664
|
+
'data-vs-btn': '', disabled: busy === 'def',
|
|
665
|
+
onClick: () => setDefaultVoice(form.defaultVoice === v.name ? '' : v.name),
|
|
666
|
+
}, form.defaultVoice === v.name ? '取消默认' : '设为默认'),
|
|
584
667
|
h('button', {
|
|
585
668
|
'data-vs-btn': '',
|
|
586
669
|
onClick: () => setForm({ ...form, defaultVoice: v.name }),
|
|
587
|
-
},
|
|
670
|
+
}, '只用于试听'),
|
|
588
671
|
h('button', {
|
|
589
672
|
'data-vs-btn': '', disabled: busy === 'del',
|
|
590
673
|
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.5.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: '',
|
|
@@ -122,6 +125,18 @@ export async function apply(ctx, config = {}) {
|
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
/* ───────────────────── 状态 ───────────────────── */
|
|
128
|
+
/**
|
|
129
|
+
* 自定义参考音频:上传过、文件还在,就返回它。
|
|
130
|
+
* 有默认音色时不需要它(音色本身就是从参考音频编出来的),所以只在没音色时用。
|
|
131
|
+
*/
|
|
132
|
+
function refOf(c) {
|
|
133
|
+
const refAudio = String(c.refAudio || '').trim()
|
|
134
|
+
const refText = String(c.refText || '').trim()
|
|
135
|
+
if (!refAudio) return null
|
|
136
|
+
if (!fileOk(refAudio)) return null
|
|
137
|
+
return { refAudio, refText }
|
|
138
|
+
}
|
|
139
|
+
|
|
125
140
|
async function statusOf() {
|
|
126
141
|
const c = cfg()
|
|
127
142
|
const lo = localOpts()
|
|
@@ -146,6 +161,12 @@ export async function apply(ctx, config = {}) {
|
|
|
146
161
|
maxChars: Number(c.maxChars) || 300,
|
|
147
162
|
},
|
|
148
163
|
local: { ...localInfo, device: lo.device, gpuIndex: lo.gpuIndex, timeoutMs: lo.timeoutMs },
|
|
164
|
+
reference: {
|
|
165
|
+
refAudio: String(c.refAudio || ''),
|
|
166
|
+
refText: String(c.refText || ''),
|
|
167
|
+
exists: Boolean(refOf(c)),
|
|
168
|
+
used: Boolean(refOf(c)) && !String(c.defaultVoice || '').trim(),
|
|
169
|
+
},
|
|
149
170
|
director: probeDirector(c),
|
|
150
171
|
cloud: {
|
|
151
172
|
baseUrl: co.baseUrl,
|
|
@@ -240,8 +261,18 @@ export async function apply(ctx, config = {}) {
|
|
|
240
261
|
const r = await synthCloud(cloudOpts(), { text: spoken, outFile, voice, instructions, guidanceScale })
|
|
241
262
|
return { ...r, ...styleMeta, ...dirMeta, file: path.basename(r.file), size: r.bytes }
|
|
242
263
|
}
|
|
243
|
-
|
|
244
|
-
|
|
264
|
+
// 没有音色 → 有自定义参考音频就用它(这样试听和 QQ 里发出去的是同一个嗓子)
|
|
265
|
+
const ref = voice ? null : refOf(c)
|
|
266
|
+
const r = await breezeSynth(localOpts(), {
|
|
267
|
+
text: spoken, outFile, voice, instructions, guidanceScale, seed,
|
|
268
|
+
refAudio: ref?.refAudio, refText: ref?.refText,
|
|
269
|
+
})
|
|
270
|
+
return {
|
|
271
|
+
...r, ...styleMeta, ...dirMeta,
|
|
272
|
+
refAudio: ref ? ref.refAudio : '',
|
|
273
|
+
file: path.basename(r.file),
|
|
274
|
+
size: fileOk(r.file) ? fs.statSync(r.file).size : null,
|
|
275
|
+
}
|
|
245
276
|
}
|
|
246
277
|
|
|
247
278
|
/** 试听:给一小段文字,用**当前设置**合出来,只为了听效果。 */
|
|
@@ -257,6 +288,63 @@ export async function apply(ctx, config = {}) {
|
|
|
257
288
|
}
|
|
258
289
|
}
|
|
259
290
|
|
|
291
|
+
/**
|
|
292
|
+
* 只出「这一句该怎么念」,不合成。
|
|
293
|
+
* 给 QQ 桥接用:让群里发出去的语音和设置页试听是**同一个嗓子、同一套语气、同一份标点**。
|
|
294
|
+
*/
|
|
295
|
+
async function voicePlan(opts = {}) {
|
|
296
|
+
const c = cfg()
|
|
297
|
+
const body = String(opts.text ?? '').trim()
|
|
298
|
+
if (!body) throw new Error('text 不能为空')
|
|
299
|
+
const vs = resolveVoiceStyle(c, opts)
|
|
300
|
+
const provider = String(opts.provider || c.provider || 'local').toLowerCase() === 'cloud' ? 'cloud' : 'local'
|
|
301
|
+
const voice = String(opts.voice ?? c.defaultVoice ?? '').trim()
|
|
302
|
+
const seed = Number.isFinite(Number(opts.seed)) ? Number(opts.seed) : Number(c.seed) || 42
|
|
303
|
+
const wantDirect = opts.direct === undefined
|
|
304
|
+
? (c.director ? c.director.enabled !== false : true)
|
|
305
|
+
: Boolean(opts.direct)
|
|
306
|
+
let spoken = body
|
|
307
|
+
let direction = null
|
|
308
|
+
if (wantDirect) {
|
|
309
|
+
direction = await directText(c, {
|
|
310
|
+
text: body,
|
|
311
|
+
styles: vs.styles,
|
|
312
|
+
instructions: vs.instructions,
|
|
313
|
+
scope: opts.scope || c.director?.scope || '',
|
|
314
|
+
})
|
|
315
|
+
if (direction.applied) spoken = direction.text
|
|
316
|
+
}
|
|
317
|
+
const lo = provider === 'local' ? localOpts() : null
|
|
318
|
+
return {
|
|
319
|
+
ok: true,
|
|
320
|
+
provider,
|
|
321
|
+
engine: provider === 'local' ? 'breeze' : 'cloud',
|
|
322
|
+
source: body,
|
|
323
|
+
spoken,
|
|
324
|
+
directed: Boolean(direction?.applied),
|
|
325
|
+
instructions: vs.instructions,
|
|
326
|
+
guidanceScale: vs.guidanceScale,
|
|
327
|
+
styles: vs.styles,
|
|
328
|
+
styleNames: vs.styleNames,
|
|
329
|
+
seed,
|
|
330
|
+
voice,
|
|
331
|
+
voicesDir: lo ? lo.voicesDir : '',
|
|
332
|
+
refAudio: voice ? '' : (refOf(c)?.refAudio || ''),
|
|
333
|
+
refText: voice ? '' : (refOf(c)?.refText || ''),
|
|
334
|
+
direction: direction
|
|
335
|
+
? {
|
|
336
|
+
ok: direction.ok,
|
|
337
|
+
applied: direction.applied,
|
|
338
|
+
notes: direction.notes,
|
|
339
|
+
emotion: direction.emotion,
|
|
340
|
+
reason: direction.reason,
|
|
341
|
+
cached: Boolean(direction.cached),
|
|
342
|
+
}
|
|
343
|
+
: null,
|
|
344
|
+
at: Date.now(),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
260
348
|
/* ───────────────────── 工具 ───────────────────── */
|
|
261
349
|
const textOut = {
|
|
262
350
|
schema: { type: 'object', properties: {}, additionalProperties: true },
|
|
@@ -454,6 +542,7 @@ export async function apply(ctx, config = {}) {
|
|
|
454
542
|
|
|
455
543
|
const ALLOW = [
|
|
456
544
|
'provider', 'instructions', 'guidanceScale', 'seed', 'maxChars', 'defaultVoice', 'styles',
|
|
545
|
+
'refAudio', 'refText',
|
|
457
546
|
'cli', 'model', 'device', 'gpuIndex', 'voicesDir', 'inboxDir', 'ffmpeg', 'maxRefSeconds', 'timeoutMs',
|
|
458
547
|
'cloudBaseUrl', 'cloudApiKey', 'cloudModel', 'cloudVoiceId', 'cloudStyle', 'cloudFormat', 'cloudGuidanceField',
|
|
459
548
|
'director', 'lifeHome', 'emotionHome', 'bridgeState',
|
|
@@ -549,6 +638,67 @@ export async function apply(ctx, config = {}) {
|
|
|
549
638
|
return sendJson(res, 200, { ok: true, rows: await listVoices(lo) })
|
|
550
639
|
}
|
|
551
640
|
|
|
641
|
+
// 自定义参考音频:上传一段音频直接当参考用(不编成音色)。
|
|
642
|
+
// body: { src?(本机路径) | data?(base64), filename?, text?(逐字转写), clear?(清掉) }
|
|
643
|
+
if (req.method === 'POST' && (sub === 'ref' || sub === 'reference')) {
|
|
644
|
+
const b = await readBody(req)
|
|
645
|
+
const lo = localOpts()
|
|
646
|
+
if (b.clear) {
|
|
647
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}), refAudio: '', refText: '' }
|
|
648
|
+
writeJson(uiConfigFile, next)
|
|
649
|
+
log('参考音频已清除')
|
|
650
|
+
return sendJson(res, 200, { ok: true, cleared: true, status: await statusOf() })
|
|
651
|
+
}
|
|
652
|
+
let audioPath = String(b.src || '').trim()
|
|
653
|
+
if (!audioPath && b.data) {
|
|
654
|
+
const filename = path.basename(String(b.filename || `ref-${Date.now()}.wav`))
|
|
655
|
+
const inbox = ensureDir(lo.inboxDir || path.join(dataRoot, 'inbox'))
|
|
656
|
+
const target = path.join(inbox, `${Date.now()}-${filename}`)
|
|
657
|
+
fs.writeFileSync(target, Buffer.from(String(b.data), 'base64'))
|
|
658
|
+
audioPath = target
|
|
659
|
+
}
|
|
660
|
+
if (!audioPath) { sendJson(res, 400, { ok: false, error: '要么给 src(本机路径),要么给 data(base64 音频)' }); return }
|
|
661
|
+
const src = resolveAudioInput(lo, audioPath)
|
|
662
|
+
const refDir = ensureDir(path.join(dataRoot, 'ref'))
|
|
663
|
+
const dst = path.join(refDir, 'ref-24k.wav')
|
|
664
|
+
const nr = await normalizeRef(lo, src, dst)
|
|
665
|
+
if (!nr.ok) return sendJson(res, 400, { ok: false, error: nr.error || '音频转换失败' })
|
|
666
|
+
const refText = String(b.text || '').trim()
|
|
667
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}), refAudio: dst, refText }
|
|
668
|
+
writeJson(uiConfigFile, next)
|
|
669
|
+
log('参考音频 →', dst)
|
|
670
|
+
return sendJson(res, 200, {
|
|
671
|
+
ok: true,
|
|
672
|
+
refAudio: dst,
|
|
673
|
+
refText,
|
|
674
|
+
warning: refText ? '' : '没给逐字转写(ref_text):引擎得靠猜这段音频在说什么,音色会不稳',
|
|
675
|
+
status: await statusOf(),
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 把某个音色设成默认(QQ 发语音也会用它)—— 设空字符串 = 取消默认
|
|
680
|
+
if (req.method === 'POST' && (sub === 'voices/default' || sub === 'voice/default')) {
|
|
681
|
+
const b = await readBody(req)
|
|
682
|
+
const lo = localOpts()
|
|
683
|
+
const want = String(b.name || '').trim()
|
|
684
|
+
if (want) {
|
|
685
|
+
const rows = await listVoices(lo)
|
|
686
|
+
if (!rows.some((r) => r.name === want)) {
|
|
687
|
+
return sendJson(res, 404, { ok: false, error: `没有这个音色:${want}`, rows })
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const next = { ...(readJsonSafe(uiConfigFile, {}) || {}), defaultVoice: want }
|
|
691
|
+
writeJson(uiConfigFile, next)
|
|
692
|
+
log('默认音色 →', want || '(空)')
|
|
693
|
+
return sendJson(res, 200, { ok: true, defaultVoice: want, status: await statusOf() })
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 只出「这一句该怎么念」,不合成:QQ 桥接用它对齐嗓子/语气/标点
|
|
697
|
+
if (req.method === 'POST' && (sub === 'voice-plan' || sub === 'plan')) {
|
|
698
|
+
const b = await readBody(req)
|
|
699
|
+
return sendJson(res, 200, await voicePlan(b))
|
|
700
|
+
}
|
|
701
|
+
|
|
552
702
|
if (req.method === 'POST' && (sub === 'synth' || sub === 'say')) {
|
|
553
703
|
const b = await readBody(req)
|
|
554
704
|
const r = await synth(b.text, {
|
|
@@ -600,8 +750,8 @@ export async function apply(ctx, config = {}) {
|
|
|
600
750
|
sendJson(res, 404, {
|
|
601
751
|
ok: false,
|
|
602
752
|
error: '没有这个接口',
|
|
603
|
-
have: ['status', 'styles', 'config', 'voices', 'voices/delete', 'synth', 'preview', 'cloud/test', 'audio',
|
|
604
|
-
'direct', 'direct/context'],
|
|
753
|
+
have: ['status', 'styles', 'config', 'voices', 'voices/delete', 'voices/default', 'ref', 'synth', 'preview', 'cloud/test', 'audio',
|
|
754
|
+
'direct', 'direct/context', 'voice-plan'],
|
|
605
755
|
})
|
|
606
756
|
} catch (e) {
|
|
607
757
|
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/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-voice-say",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "dsh-voice-say:把一句话合成语音。两条通道(本地 Breeze TTS 2 / 云端 apiKey+voiceId)共用 instructions + guidanceScale;18 个风格可叠加;合成前由「读音导演」按人设、当下情绪与对话情节重排标点;支持上传本地音色、自定义参考音频与试听片段。QQ 桥接会先问插件,群里发出去的语音与设置页试听同源同风格。",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "dsh-voice-say contributors",
|
|
8
8
|
"keywords": [
|