dsh-audiogen 0.2.0 → 0.3.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/lib/client.js +298 -270
- package/lib/client.js.map +1 -1
- package/lib/index.js +83 -11
- package/package.json +1 -1
- package/src/agent-audio-tools.ts +19 -5
- package/src/audio-engine.ts +50 -7
- package/src/audio-store.ts +2 -0
- package/src/client/AudioGenPanel.tsx +71 -35
- package/src/client/locales.ts +4 -2
- package/src/protocol.ts +10 -2
- package/src/routes.ts +7 -1
package/lib/index.js
CHANGED
|
@@ -285,6 +285,38 @@ async function minimax(channel, request, signal) {
|
|
|
285
285
|
const base = minimaxApiBase(channel.apiUrl);
|
|
286
286
|
const model = (request.upstream ?? request.model) || (request.mode === "music" ? "music-3.0" : "speech-2.8-hd");
|
|
287
287
|
const voice = request.voice ?? request.model ?? "";
|
|
288
|
+
if (request.mode === "voice_design") {
|
|
289
|
+
const endpoint = `${base}/voice_design`;
|
|
290
|
+
const body = {
|
|
291
|
+
prompt: request.prompt,
|
|
292
|
+
preview_text: request.previewText ?? request.voice ?? "你好,这是新设计的音色试听。"
|
|
293
|
+
};
|
|
294
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
redirect: "error",
|
|
297
|
+
headers: {
|
|
298
|
+
authorization: `Bearer ${channel.apiKey.trim()}`,
|
|
299
|
+
"content-type": "application/json",
|
|
300
|
+
accept: "application/json"
|
|
301
|
+
},
|
|
302
|
+
body: JSON.stringify(body),
|
|
303
|
+
signal
|
|
304
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
305
|
+
if (!response.ok) {
|
|
306
|
+
const detail = await response.text().catch(() => "");
|
|
307
|
+
throw new AudioGenError(`MiniMax voice design API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
|
|
308
|
+
}
|
|
309
|
+
const payload = await response.json();
|
|
310
|
+
if (payload.base_resp?.status_code !== void 0 && payload.base_resp.status_code !== 0) throw new AudioGenError(payload.base_resp.status_msg ?? `MiniMax returned status ${payload.base_resp.status_code}`, "audio-api-error");
|
|
311
|
+
const encoded = payload.trial_audio ?? "";
|
|
312
|
+
if (encoded === "") throw new AudioGenError("MiniMax voice design returned no trial audio", "audio-empty-result");
|
|
313
|
+
const isHex = /^[0-9a-fA-F]+$/.test(encoded) && encoded.length % 2 === 0;
|
|
314
|
+
return [{
|
|
315
|
+
data: new Uint8Array(Buffer.from(encoded, isHex ? "hex" : "base64")),
|
|
316
|
+
mime: "audio/mpeg",
|
|
317
|
+
...payload.voice_id === void 0 ? {} : { voiceId: payload.voice_id }
|
|
318
|
+
}];
|
|
319
|
+
}
|
|
288
320
|
let endpoint;
|
|
289
321
|
let body;
|
|
290
322
|
if (request.mode === "music") {
|
|
@@ -392,6 +424,7 @@ async function generateAudio(channel, request, signal) {
|
|
|
392
424
|
if (channel.apiUrl.trim() === "") throw new AudioGenError("channel API URL is not configured", "audio-no-endpoint");
|
|
393
425
|
if (channel.apiKey.trim() === "") throw new AudioGenError("channel API key is not configured", "audio-no-key");
|
|
394
426
|
if (request.prompt.trim() === "") throw new AudioGenError("audio prompt/text is required", "audio-empty-prompt");
|
|
427
|
+
if (request.mode === "voice_design" && !isMiniMax$1(channel)) throw new AudioGenError("音色设计当前仅支持 MiniMax 渠道", "voice-design-unsupported");
|
|
395
428
|
if (isElevenLabs(channel)) return elevenLabs(channel, request, signal);
|
|
396
429
|
if (isMiniMax$1(channel)) return minimax(channel, request, signal);
|
|
397
430
|
if (isStability(channel)) return stabilityAudio(channel, request, signal);
|
|
@@ -731,13 +764,15 @@ async function appendHistory(entry) {
|
|
|
731
764
|
model: entry.model,
|
|
732
765
|
prompt: entry.prompt,
|
|
733
766
|
...entry.voice === void 0 ? {} : { voice: entry.voice },
|
|
767
|
+
...entry.voiceId === void 0 ? {} : { voiceId: entry.voiceId },
|
|
734
768
|
...entry.speed === void 0 ? {} : { speed: entry.speed },
|
|
735
769
|
...entry.duration === void 0 ? {} : { duration: entry.duration },
|
|
736
770
|
...entry.format === void 0 ? {} : { format: entry.format },
|
|
737
771
|
audio: entry.audio.map((audio) => ({
|
|
738
772
|
url: audio.url,
|
|
739
773
|
mime: audio.mime,
|
|
740
|
-
...audio.duration === void 0 ? {} : { duration: audio.duration }
|
|
774
|
+
...audio.duration === void 0 ? {} : { duration: audio.duration },
|
|
775
|
+
...audio.voiceId === void 0 ? {} : { voiceId: audio.voiceId }
|
|
741
776
|
})),
|
|
742
777
|
...entry.channelId === void 0 ? {} : { channelId: entry.channelId },
|
|
743
778
|
...entry.channel === void 0 ? {} : { channel: entry.channel }
|
|
@@ -809,7 +844,7 @@ function messageOf(error) {
|
|
|
809
844
|
return error instanceof Error ? error.message : String(error);
|
|
810
845
|
}
|
|
811
846
|
function parseGenerateRequest(body) {
|
|
812
|
-
const mode = body.mode === "music" ? "music" : body.mode === "sfx" ? "sfx" : "tts";
|
|
847
|
+
const mode = body.mode === "music" ? "music" : body.mode === "sfx" ? "sfx" : body.mode === "voice_design" ? "voice_design" : "tts";
|
|
813
848
|
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
814
849
|
if (prompt === "") return void 0;
|
|
815
850
|
return {
|
|
@@ -817,6 +852,7 @@ function parseGenerateRequest(body) {
|
|
|
817
852
|
model: typeof body.model === "string" ? body.model.trim() : "",
|
|
818
853
|
prompt,
|
|
819
854
|
...typeof body.voice === "string" && body.voice.trim() !== "" ? { voice: body.voice.trim() } : {},
|
|
855
|
+
...typeof body.previewText === "string" && body.previewText.trim() !== "" ? { previewText: body.previewText.trim() } : {},
|
|
820
856
|
...typeof body.speed === "number" ? { speed: body.speed } : {},
|
|
821
857
|
...typeof body.duration === "number" ? { duration: body.duration } : {},
|
|
822
858
|
...typeof body.format === "string" && body.format.trim() !== "" ? { format: body.format.trim() } : {},
|
|
@@ -863,6 +899,21 @@ function resolveChannelRequest(request, view) {
|
|
|
863
899
|
const target = explicit ?? defaults;
|
|
864
900
|
const asked = request.model.trim();
|
|
865
901
|
if (asked === "") {
|
|
902
|
+
if (request.mode === "voice_design") {
|
|
903
|
+
if (target === void 0) return {
|
|
904
|
+
ok: false,
|
|
905
|
+
code: "no-channels",
|
|
906
|
+
message: "尚未配置任何渠道"
|
|
907
|
+
};
|
|
908
|
+
return {
|
|
909
|
+
ok: true,
|
|
910
|
+
request: {
|
|
911
|
+
...request,
|
|
912
|
+
channelId: target.id,
|
|
913
|
+
channel: target.name
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
}
|
|
866
917
|
const alias = target?.models[0]?.alias ?? "";
|
|
867
918
|
if (alias === "") return {
|
|
868
919
|
ok: false,
|
|
@@ -1063,7 +1114,8 @@ function makeRoutes(deps) {
|
|
|
1063
1114
|
b64: Buffer.from(output.data).toString("base64"),
|
|
1064
1115
|
mime: saved.mime,
|
|
1065
1116
|
bytes: saved.bytes,
|
|
1066
|
-
url: `${AUDIO_API.file}/${encodeURIComponent(saved.file)}
|
|
1117
|
+
url: `${AUDIO_API.file}/${encodeURIComponent(saved.file)}`,
|
|
1118
|
+
...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
|
|
1067
1119
|
});
|
|
1068
1120
|
}
|
|
1069
1121
|
let history;
|
|
@@ -1220,7 +1272,8 @@ const resultSchema = {
|
|
|
1220
1272
|
enum: [
|
|
1221
1273
|
"tts",
|
|
1222
1274
|
"music",
|
|
1223
|
-
"sfx"
|
|
1275
|
+
"sfx",
|
|
1276
|
+
"voice_design"
|
|
1224
1277
|
]
|
|
1225
1278
|
},
|
|
1226
1279
|
model: {
|
|
@@ -1249,7 +1302,8 @@ const resultSchema = {
|
|
|
1249
1302
|
bytes: {
|
|
1250
1303
|
type: "integer",
|
|
1251
1304
|
required: true
|
|
1252
|
-
}
|
|
1305
|
+
},
|
|
1306
|
+
voiceId: { type: "string" }
|
|
1253
1307
|
}
|
|
1254
1308
|
}
|
|
1255
1309
|
},
|
|
@@ -1287,7 +1341,7 @@ function ensureConfigured(config) {
|
|
|
1287
1341
|
function registerAgentAudioTools(ctx, resolve) {
|
|
1288
1342
|
return ctx.tools.register(defineTool({
|
|
1289
1343
|
name: "generate_audio",
|
|
1290
|
-
description: "Generate audio with the configured audio provider. Supports text-to-speech, music generation and
|
|
1344
|
+
description: "Generate audio with the configured audio provider. Supports text-to-speech, music generation, sound effects and MiniMax voice design. The tool call waits for the upstream result and returns same-origin audio URLs; pass those URLs to the user for playback or download. If multiple models are configured, first ask the user which one to use or pass model explicitly.",
|
|
1291
1345
|
parameters: {
|
|
1292
1346
|
prompt: {
|
|
1293
1347
|
type: "string",
|
|
@@ -1299,7 +1353,8 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1299
1353
|
enum: [
|
|
1300
1354
|
"tts",
|
|
1301
1355
|
"music",
|
|
1302
|
-
"sfx"
|
|
1356
|
+
"sfx",
|
|
1357
|
+
"voice_design"
|
|
1303
1358
|
],
|
|
1304
1359
|
description: "Generation mode. Defaults to tts."
|
|
1305
1360
|
},
|
|
@@ -1311,6 +1366,10 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1311
1366
|
type: "string",
|
|
1312
1367
|
description: "Optional voice id/name for TTS providers."
|
|
1313
1368
|
},
|
|
1369
|
+
preview_text: {
|
|
1370
|
+
type: "string",
|
|
1371
|
+
description: "Optional preview text for voice_design."
|
|
1372
|
+
},
|
|
1314
1373
|
speed: {
|
|
1315
1374
|
type: "number",
|
|
1316
1375
|
description: "Optional speaking rate / speed multiplier where supported."
|
|
@@ -1333,15 +1392,26 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1333
1392
|
async execute(args, exec) {
|
|
1334
1393
|
const config = resolve();
|
|
1335
1394
|
ensureConfigured(config);
|
|
1336
|
-
const
|
|
1395
|
+
const mode = args.mode === "music" ? "music" : args.mode === "sfx" ? "sfx" : args.mode === "voice_design" ? "voice_design" : "tts";
|
|
1396
|
+
const picked = mode === "voice_design" ? (() => {
|
|
1397
|
+
const usable = config.channels.filter((channel) => channel.apiUrl.trim() !== "" && channel.apiKey.trim() !== "");
|
|
1398
|
+
const target = usable.find((channel) => channel.id === config.defaultChannelId) ?? usable[0];
|
|
1399
|
+
if (target === void 0) throw new AudioGenError("No usable audio channel is configured for voice design.", "no-channel-available");
|
|
1400
|
+
return {
|
|
1401
|
+
channel: target,
|
|
1402
|
+
alias: "",
|
|
1403
|
+
upstream: ""
|
|
1404
|
+
};
|
|
1405
|
+
})() : resolveModel(config, args.model);
|
|
1337
1406
|
const request = {
|
|
1338
|
-
mode
|
|
1407
|
+
mode,
|
|
1339
1408
|
model: picked.alias,
|
|
1340
1409
|
upstream: picked.upstream,
|
|
1341
1410
|
channelId: picked.channel.id,
|
|
1342
1411
|
channel: picked.channel.name,
|
|
1343
1412
|
prompt: args.prompt.trim(),
|
|
1344
1413
|
...typeof args.voice === "string" && args.voice.trim() !== "" ? { voice: args.voice.trim() } : {},
|
|
1414
|
+
...typeof args.preview_text === "string" && args.preview_text.trim() !== "" ? { previewText: args.preview_text.trim() } : {},
|
|
1345
1415
|
...typeof args.speed === "number" ? { speed: args.speed } : {},
|
|
1346
1416
|
...typeof args.duration === "number" ? { duration: args.duration } : {},
|
|
1347
1417
|
...typeof args.format === "string" && args.format.trim() !== "" ? { format: args.format.trim() } : {}
|
|
@@ -1355,7 +1425,8 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1355
1425
|
id: saved.id,
|
|
1356
1426
|
url: `/api/dsh-audiogen/audio/${encodeURIComponent(saved.file)}`,
|
|
1357
1427
|
mime: saved.mime,
|
|
1358
|
-
bytes: saved.bytes
|
|
1428
|
+
bytes: saved.bytes,
|
|
1429
|
+
...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
|
|
1359
1430
|
});
|
|
1360
1431
|
}
|
|
1361
1432
|
try {
|
|
@@ -1374,7 +1445,8 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1374
1445
|
b64: Buffer.from(output.data).toString("base64"),
|
|
1375
1446
|
mime: audio[index].mime,
|
|
1376
1447
|
bytes: audio[index].bytes,
|
|
1377
|
-
url: audio[index].url
|
|
1448
|
+
url: audio[index].url,
|
|
1449
|
+
...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
|
|
1378
1450
|
})),
|
|
1379
1451
|
channelId: picked.channel.id,
|
|
1380
1452
|
channel: picked.channel.name
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-audiogen",
|
|
3
3
|
"description": "AI audio generation plugin for the dsh web GUI: multi-vendor TTS/music/sound-effect channels (OpenAI-compatible, ElevenLabs, MiniMax, Stability AI and custom), per-channel model/voice catalogs, Agent tool and a sidebar AI 音频 panel.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/src/agent-audio-tools.ts
CHANGED
|
@@ -24,6 +24,7 @@ interface AgentAudioRef {
|
|
|
24
24
|
url: string
|
|
25
25
|
mime: string
|
|
26
26
|
bytes: number
|
|
27
|
+
voiceId?: string
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
interface AgentAudioResult {
|
|
@@ -43,6 +44,7 @@ const audioRefSchema = {
|
|
|
43
44
|
url: { type: 'string', required: true },
|
|
44
45
|
mime: { type: 'string', required: true },
|
|
45
46
|
bytes: { type: 'integer', required: true },
|
|
47
|
+
voiceId: { type: 'string' },
|
|
46
48
|
},
|
|
47
49
|
} as const
|
|
48
50
|
|
|
@@ -52,7 +54,7 @@ const resultSchema = {
|
|
|
52
54
|
properties: {
|
|
53
55
|
status: { type: 'string', required: true },
|
|
54
56
|
message: { type: 'string', required: true },
|
|
55
|
-
mode: { type: 'string', required: true, enum: ['tts', 'music', 'sfx'] },
|
|
57
|
+
mode: { type: 'string', required: true, enum: ['tts', 'music', 'sfx', 'voice_design'] },
|
|
56
58
|
model: { type: 'string', required: true },
|
|
57
59
|
audio: { type: 'array', required: true, items: audioRefSchema },
|
|
58
60
|
error: { type: 'string' },
|
|
@@ -98,12 +100,13 @@ function ensureConfigured(config: AgentAudioToolConfig): void {
|
|
|
98
100
|
export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioToolConfig): () => void {
|
|
99
101
|
const disposer = ctx.tools.register(defineTool({
|
|
100
102
|
name: 'generate_audio',
|
|
101
|
-
description: 'Generate audio with the configured audio provider. Supports text-to-speech, music generation and
|
|
103
|
+
description: 'Generate audio with the configured audio provider. Supports text-to-speech, music generation, sound effects and MiniMax voice design. The tool call waits for the upstream result and returns same-origin audio URLs; pass those URLs to the user for playback or download. If multiple models are configured, first ask the user which one to use or pass model explicitly.',
|
|
102
104
|
parameters: {
|
|
103
105
|
prompt: { type: 'string', required: true, description: 'For tts, the text to speak. For music/sfx, a descriptive prompt.' },
|
|
104
|
-
mode: { type: 'string', enum: ['tts', 'music', 'sfx'], description: 'Generation mode. Defaults to tts.' },
|
|
106
|
+
mode: { type: 'string', enum: ['tts', 'music', 'sfx', 'voice_design'], description: 'Generation mode. Defaults to tts.' },
|
|
105
107
|
model: { type: 'string', description: 'One of the configured audio models/voices. Defaults to the first configured model.' },
|
|
106
108
|
voice: { type: 'string', description: 'Optional voice id/name for TTS providers.' },
|
|
109
|
+
preview_text: { type: 'string', description: 'Optional preview text for voice_design.' },
|
|
107
110
|
speed: { type: 'number', description: 'Optional speaking rate / speed multiplier where supported.' },
|
|
108
111
|
duration: { type: 'number', description: 'Requested duration in seconds for music/sfx.' },
|
|
109
112
|
format: { type: 'string', description: 'Output format such as mp3 or wav.' },
|
|
@@ -117,15 +120,24 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
|
|
|
117
120
|
async execute(args, exec) {
|
|
118
121
|
const config = resolve()
|
|
119
122
|
ensureConfigured(config)
|
|
120
|
-
const
|
|
123
|
+
const mode = args.mode === 'music' ? 'music' : args.mode === 'sfx' ? 'sfx' : args.mode === 'voice_design' ? 'voice_design' : 'tts'
|
|
124
|
+
const picked = mode === 'voice_design'
|
|
125
|
+
? (() => {
|
|
126
|
+
const usable = config.channels.filter(channel => channel.apiUrl.trim() !== '' && channel.apiKey.trim() !== '')
|
|
127
|
+
const target = usable.find(channel => channel.id === config.defaultChannelId) ?? usable[0]
|
|
128
|
+
if (target === undefined) throw new AudioGenError('No usable audio channel is configured for voice design.', 'no-channel-available')
|
|
129
|
+
return { channel: target, alias: '', upstream: '' }
|
|
130
|
+
})()
|
|
131
|
+
: resolveModel(config, args.model)
|
|
121
132
|
const request: GenerateAudioRequest = {
|
|
122
|
-
mode
|
|
133
|
+
mode,
|
|
123
134
|
model: picked.alias,
|
|
124
135
|
upstream: picked.upstream,
|
|
125
136
|
channelId: picked.channel.id,
|
|
126
137
|
channel: picked.channel.name,
|
|
127
138
|
prompt: args.prompt.trim(),
|
|
128
139
|
...(typeof args.voice === 'string' && args.voice.trim() !== '' ? { voice: args.voice.trim() } : {}),
|
|
140
|
+
...(typeof args.preview_text === 'string' && args.preview_text.trim() !== '' ? { previewText: args.preview_text.trim() } : {}),
|
|
129
141
|
...(typeof args.speed === 'number' ? { speed: args.speed } : {}),
|
|
130
142
|
...(typeof args.duration === 'number' ? { duration: args.duration } : {}),
|
|
131
143
|
...(typeof args.format === 'string' && args.format.trim() !== '' ? { format: args.format.trim() } : {}),
|
|
@@ -140,6 +152,7 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
|
|
|
140
152
|
url: `/api/dsh-audiogen/audio/${encodeURIComponent(saved.file)}`,
|
|
141
153
|
mime: saved.mime,
|
|
142
154
|
bytes: saved.bytes,
|
|
155
|
+
...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
|
|
143
156
|
})
|
|
144
157
|
}
|
|
145
158
|
try {
|
|
@@ -159,6 +172,7 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
|
|
|
159
172
|
mime: audio[index]!.mime,
|
|
160
173
|
bytes: audio[index]!.bytes,
|
|
161
174
|
url: audio[index]!.url,
|
|
175
|
+
...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
|
|
162
176
|
})),
|
|
163
177
|
channelId: picked.channel.id,
|
|
164
178
|
channel: picked.channel.name,
|
package/src/audio-engine.ts
CHANGED
|
@@ -164,7 +164,7 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
|
|
|
164
164
|
async function normalizeAudioResponse(
|
|
165
165
|
response: Response,
|
|
166
166
|
options: { apiKey: string; fallbackMime?: string },
|
|
167
|
-
): Promise<Array<{ data: Uint8Array; mime: string }>> {
|
|
167
|
+
): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
|
168
168
|
if (!response.ok) {
|
|
169
169
|
let detail = ''
|
|
170
170
|
try {
|
|
@@ -213,7 +213,7 @@ async function normalizeAudioResponse(
|
|
|
213
213
|
return [{ data: buffer, mime: audioMime(buffer, response.headers.get('content-type') ?? contentType ?? null) }]
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
async function openAITTS(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
|
|
216
|
+
async function openAITTS(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
|
217
217
|
const base = endpointBase(channel.apiUrl)
|
|
218
218
|
const endpoint = /\/audio\/speech(\?|$)/i.test(base) ? base : `${base}/audio/speech`
|
|
219
219
|
const model = (request.upstream ?? request.model) || 'tts-1'
|
|
@@ -239,7 +239,7 @@ async function openAITTS(channel: AudioChannel, request: GenerateAudioRequest, s
|
|
|
239
239
|
return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
async function elevenLabs(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
|
|
242
|
+
async function elevenLabs(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
|
243
243
|
const base = endpointBase(channel.apiUrl)
|
|
244
244
|
const model = (request.upstream ?? request.model) || 'eleven_multilingual_v2'
|
|
245
245
|
const voiceId = (request.voice ?? request.model ?? model).trim()
|
|
@@ -274,11 +274,51 @@ function minimaxApiBase(base: string): string {
|
|
|
274
274
|
return /\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/v1`
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
async function minimax(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
|
|
277
|
+
async function minimax(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
|
278
278
|
const base = minimaxApiBase(channel.apiUrl)
|
|
279
279
|
const model = (request.upstream ?? request.model) || (request.mode === 'music' ? 'music-3.0' : 'speech-2.8-hd')
|
|
280
280
|
const voice = request.voice ?? request.model ?? ''
|
|
281
281
|
|
|
282
|
+
if (request.mode === 'voice_design') {
|
|
283
|
+
const endpoint = `${base}/voice_design`
|
|
284
|
+
const body: Record<string, unknown> = {
|
|
285
|
+
prompt: request.prompt,
|
|
286
|
+
preview_text: request.previewText ?? request.voice ?? '你好,这是新设计的音色试听。',
|
|
287
|
+
}
|
|
288
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
redirect: 'error',
|
|
291
|
+
headers: {
|
|
292
|
+
authorization: `Bearer ${channel.apiKey.trim()}`,
|
|
293
|
+
'content-type': 'application/json',
|
|
294
|
+
accept: 'application/json',
|
|
295
|
+
},
|
|
296
|
+
body: JSON.stringify(body),
|
|
297
|
+
signal,
|
|
298
|
+
}, UPSTREAM_TIMEOUT_MS)
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
const detail = await response.text().catch(() => '')
|
|
301
|
+
throw new AudioGenError(`MiniMax voice design API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`, 'audio-api-error')
|
|
302
|
+
}
|
|
303
|
+
const payload = await response.json() as {
|
|
304
|
+
voice_id?: string
|
|
305
|
+
trial_audio?: string
|
|
306
|
+
base_resp?: { status_code?: number; status_msg?: string }
|
|
307
|
+
}
|
|
308
|
+
if (payload.base_resp?.status_code !== undefined && payload.base_resp.status_code !== 0) {
|
|
309
|
+
throw new AudioGenError(payload.base_resp.status_msg ?? `MiniMax returned status ${payload.base_resp.status_code}`, 'audio-api-error')
|
|
310
|
+
}
|
|
311
|
+
const encoded = payload.trial_audio ?? ''
|
|
312
|
+
if (encoded === '') throw new AudioGenError('MiniMax voice design returned no trial audio', 'audio-empty-result')
|
|
313
|
+
const isHex = /^[0-9a-fA-F]+$/.test(encoded) && encoded.length % 2 === 0
|
|
314
|
+
const data = new Uint8Array(Buffer.from(encoded, isHex ? 'hex' : 'base64'))
|
|
315
|
+
return [{
|
|
316
|
+
data,
|
|
317
|
+
mime: 'audio/mpeg',
|
|
318
|
+
...(payload.voice_id === undefined ? {} : { voiceId: payload.voice_id }),
|
|
319
|
+
}]
|
|
320
|
+
}
|
|
321
|
+
|
|
282
322
|
let endpoint: string
|
|
283
323
|
let body: Record<string, unknown>
|
|
284
324
|
if (request.mode === 'music') {
|
|
@@ -327,7 +367,7 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
|
|
|
327
367
|
return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
|
|
328
368
|
}
|
|
329
369
|
|
|
330
|
-
async function stabilityAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
|
|
370
|
+
async function stabilityAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
|
331
371
|
const base = endpointBase(channel.apiUrl)
|
|
332
372
|
const endpoint = /\/generation(\?|$)/i.test(base) ? base : `${base}/generation`
|
|
333
373
|
const model = (request.upstream ?? request.model) || 'stable-audio-2.0'
|
|
@@ -351,7 +391,7 @@ async function stabilityAudio(channel: AudioChannel, request: GenerateAudioReque
|
|
|
351
391
|
return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
|
|
352
392
|
}
|
|
353
393
|
|
|
354
|
-
async function genericAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
|
|
394
|
+
async function genericAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
|
355
395
|
const base = endpointBase(channel.apiUrl)
|
|
356
396
|
if (request.mode === 'tts' && !/\/generate(\?|$)/i.test(base)) {
|
|
357
397
|
return openAITTS(channel, request, signal)
|
|
@@ -388,10 +428,13 @@ export async function generateAudio(
|
|
|
388
428
|
channel: AudioChannel,
|
|
389
429
|
request: GenerateAudioRequest,
|
|
390
430
|
signal?: AbortSignal,
|
|
391
|
-
): Promise<Array<{ data: Uint8Array; mime: string }>> {
|
|
431
|
+
): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
|
392
432
|
if (channel.apiUrl.trim() === '') throw new AudioGenError('channel API URL is not configured', 'audio-no-endpoint')
|
|
393
433
|
if (channel.apiKey.trim() === '') throw new AudioGenError('channel API key is not configured', 'audio-no-key')
|
|
394
434
|
if (request.prompt.trim() === '') throw new AudioGenError('audio prompt/text is required', 'audio-empty-prompt')
|
|
435
|
+
if (request.mode === 'voice_design' && !isMiniMax(channel)) {
|
|
436
|
+
throw new AudioGenError('音色设计当前仅支持 MiniMax 渠道', 'voice-design-unsupported')
|
|
437
|
+
}
|
|
395
438
|
|
|
396
439
|
if (isElevenLabs(channel)) return elevenLabs(channel, request, signal)
|
|
397
440
|
if (isMiniMax(channel)) return minimax(channel, request, signal)
|
package/src/audio-store.ts
CHANGED
|
@@ -99,6 +99,7 @@ export async function appendHistory(entry: HistoryEntryInput): Promise<HistoryEn
|
|
|
99
99
|
model: entry.model,
|
|
100
100
|
prompt: entry.prompt,
|
|
101
101
|
...(entry.voice === undefined ? {} : { voice: entry.voice }),
|
|
102
|
+
...(entry.voiceId === undefined ? {} : { voiceId: entry.voiceId }),
|
|
102
103
|
...(entry.speed === undefined ? {} : { speed: entry.speed }),
|
|
103
104
|
...(entry.duration === undefined ? {} : { duration: entry.duration }),
|
|
104
105
|
...(entry.format === undefined ? {} : { format: entry.format }),
|
|
@@ -106,6 +107,7 @@ export async function appendHistory(entry: HistoryEntryInput): Promise<HistoryEn
|
|
|
106
107
|
url: audio.url,
|
|
107
108
|
mime: audio.mime,
|
|
108
109
|
...(audio.duration === undefined ? {} : { duration: audio.duration }),
|
|
110
|
+
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
109
111
|
})),
|
|
110
112
|
...(entry.channelId === undefined ? {} : { channelId: entry.channelId }),
|
|
111
113
|
...(entry.channel === undefined ? {} : { channel: entry.channel }),
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
|
|
1
2
|
/**
|
|
2
3
|
* The AI 音频 panel: a compact audio-generation studio.
|
|
4
|
+
* TTS / music / SFX / voice design are separated; each mode only lists
|
|
5
|
+
* compatible models and shows its own parameters.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import { useEffect, useMemo, useState } from 'react'
|
|
@@ -7,7 +10,7 @@ import type { AudiogenApi } from './api.ts'
|
|
|
7
10
|
import type { AudiogenScope } from './settings-scope.ts'
|
|
8
11
|
import { audioModelOptions } from './settings-scope.ts'
|
|
9
12
|
import { tt } from './helpers.ts'
|
|
10
|
-
import {
|
|
13
|
+
import { HISTORY_API, type AudioMode, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
|
|
11
14
|
import css from './audio-panel.module.css'
|
|
12
15
|
|
|
13
16
|
function useConfig(scope: AudiogenScope) {
|
|
@@ -47,11 +50,12 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
47
50
|
const channels = config?.channels ?? []
|
|
48
51
|
const connected = enabled && channels.some(channel => {
|
|
49
52
|
const keyHeld = scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)
|
|
50
|
-
return channel.apiUrl.trim() !== '' && keyHeld && channel.models.length > 0
|
|
53
|
+
return channel.apiUrl.trim() !== '' && keyHeld && (channel.models.length > 0 || channel.preset === 'minimax')
|
|
51
54
|
})
|
|
52
55
|
|
|
53
56
|
const [mode, setMode] = useState<AudioMode>('tts')
|
|
54
57
|
const [prompt, setPrompt] = useState('')
|
|
58
|
+
const [previewText, setPreviewText] = useState('')
|
|
55
59
|
const [model, setModel] = useState('')
|
|
56
60
|
const [voice, setVoice] = useState('')
|
|
57
61
|
const [speed, setSpeed] = useState('')
|
|
@@ -62,9 +66,12 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
62
66
|
const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
|
|
63
67
|
const { entries, reload, clear } = useHistory()
|
|
64
68
|
|
|
65
|
-
const visibleModels = useMemo(() =>
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
const visibleModels = useMemo(() => {
|
|
70
|
+
if (mode === 'voice_design') return []
|
|
71
|
+
return modelOptions.models
|
|
72
|
+
.filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
|
|
73
|
+
.map(entry => entry.alias)
|
|
74
|
+
}, [modelOptions.models, mode])
|
|
68
75
|
|
|
69
76
|
useEffect(() => {
|
|
70
77
|
if (visibleModels.length > 0 && !visibleModels.includes(model)) {
|
|
@@ -84,6 +91,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
84
91
|
mode,
|
|
85
92
|
model: (model || visibleModels[0]) ?? '',
|
|
86
93
|
prompt: prompt.trim(),
|
|
94
|
+
...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
|
|
87
95
|
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
88
96
|
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
89
97
|
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
@@ -105,9 +113,12 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
105
113
|
const modeLabel = useMemo(() => {
|
|
106
114
|
if (mode === 'tts') return tt('mode.tts')
|
|
107
115
|
if (mode === 'music') return tt('mode.music')
|
|
108
|
-
return tt('mode.sfx')
|
|
116
|
+
if (mode === 'sfx') return tt('mode.sfx')
|
|
117
|
+
return tt('mode.voiceDesign')
|
|
109
118
|
}, [mode])
|
|
110
119
|
|
|
120
|
+
const needModel = mode !== 'voice_design'
|
|
121
|
+
|
|
111
122
|
return (
|
|
112
123
|
<div className={css.panel}>
|
|
113
124
|
<header className={css.header}>
|
|
@@ -117,7 +128,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
117
128
|
<div className={css.layout}>
|
|
118
129
|
<div className={css.form}>
|
|
119
130
|
<div className={css.modeRow}>
|
|
120
|
-
{(['tts', 'music', 'sfx'] as const).map(item => (
|
|
131
|
+
{(['tts', 'music', 'sfx', 'voice_design'] as const).map(item => (
|
|
121
132
|
<button
|
|
122
133
|
key={item}
|
|
123
134
|
type="button"
|
|
@@ -125,50 +136,73 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
125
136
|
data-active={mode === item ? 'true' : 'false'}
|
|
126
137
|
onClick={() => setMode(item)}
|
|
127
138
|
>
|
|
128
|
-
{item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : tt('mode.sfx')}
|
|
139
|
+
{item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : item === 'sfx' ? tt('mode.sfx') : tt('mode.voiceDesign')}
|
|
129
140
|
</button>
|
|
130
141
|
))}
|
|
131
142
|
</div>
|
|
143
|
+
|
|
132
144
|
<label className={css.label}>
|
|
133
|
-
<span>{mode === 'tts' ? '文本' : '提示词'}</span>
|
|
145
|
+
<span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
|
|
134
146
|
<textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
|
|
135
147
|
</label>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
</
|
|
142
|
-
|
|
148
|
+
|
|
149
|
+
{mode === 'voice_design' ? (
|
|
150
|
+
<label className={css.label}>
|
|
151
|
+
<span>试听文本</span>
|
|
152
|
+
<input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
|
|
153
|
+
</label>
|
|
154
|
+
) : null}
|
|
155
|
+
|
|
156
|
+
{needModel ? (
|
|
157
|
+
<label className={css.label}>
|
|
158
|
+
<span>{tt('model.label')}</span>
|
|
159
|
+
<select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
|
|
160
|
+
{visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
|
|
161
|
+
{visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
|
|
162
|
+
</select>
|
|
163
|
+
</label>
|
|
164
|
+
) : null}
|
|
165
|
+
|
|
143
166
|
{mode === 'tts' ? (
|
|
144
167
|
<label className={css.label}>
|
|
145
168
|
<span>{tt('voice.label')}</span>
|
|
146
169
|
<input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder="alloy / 自定义音色" />
|
|
147
170
|
</label>
|
|
148
171
|
) : null}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
|
|
173
|
+
{mode === 'tts' ? (
|
|
174
|
+
<label className={css.label}>
|
|
175
|
+
<span>{tt('speed.label')}</span>
|
|
176
|
+
<input className={css.input} type="number" step="0.1" min="0.5" max="2" value={speed} onChange={event => setSpeed(event.target.value)} placeholder="1.0" />
|
|
177
|
+
</label>
|
|
178
|
+
) : null}
|
|
179
|
+
|
|
180
|
+
{mode === 'music' || mode === 'sfx' ? (
|
|
181
|
+
<label className={css.label}>
|
|
182
|
+
<span>{tt('duration.label')}</span>
|
|
183
|
+
<input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
|
|
184
|
+
</label>
|
|
185
|
+
) : null}
|
|
186
|
+
|
|
187
|
+
{needModel ? (
|
|
188
|
+
<label className={css.label}>
|
|
189
|
+
<span>{tt('format.label')}</span>
|
|
190
|
+
<select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
|
|
191
|
+
<option value="mp3">mp3</option>
|
|
192
|
+
<option value="wav">wav</option>
|
|
193
|
+
<option value="flac">flac</option>
|
|
194
|
+
<option value="ogg">ogg</option>
|
|
195
|
+
<option value="pcm">pcm</option>
|
|
196
|
+
</select>
|
|
197
|
+
</label>
|
|
198
|
+
) : null}
|
|
199
|
+
|
|
167
200
|
{!connected && <p className={css.hint}>{tt('config.missing')}</p>}
|
|
168
|
-
<button type="button" className={css.generate} disabled={loading || !connected || visibleModels.length === 0} onClick={() => void submit()}>
|
|
201
|
+
<button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
|
|
169
202
|
{loading ? tt('generating') : tt('generate')}
|
|
170
203
|
</button>
|
|
171
204
|
</div>
|
|
205
|
+
|
|
172
206
|
<div className={css.result}>
|
|
173
207
|
{error !== null ? <p className={css.error}>{error}</p> : null}
|
|
174
208
|
{outputs.length === 0 ? <p className={css.empty}>{tt('result.empty')}</p> : (
|
|
@@ -177,6 +211,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
177
211
|
<div className={css.audioList}>
|
|
178
212
|
{outputs.map((audio, index) => (
|
|
179
213
|
<div className={css.audioCard} key={audio.id}>
|
|
214
|
+
{audio.voiceId !== undefined ? <p className={css.hint}>新音色 ID:{audio.voiceId}</p> : null}
|
|
180
215
|
<audio className={css.audio} controls preload="metadata" src={dataUrlOf(audio)} />
|
|
181
216
|
<a className={css.download} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>下载</a>
|
|
182
217
|
</div>
|
|
@@ -185,6 +220,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
185
220
|
</>
|
|
186
221
|
)}
|
|
187
222
|
</div>
|
|
223
|
+
|
|
188
224
|
<aside className={css.history}>
|
|
189
225
|
<div className={css.historyHeader}>
|
|
190
226
|
<strong className={css.historyTitle}>{tt('history.title')}</strong>
|
package/src/client/locales.ts
CHANGED
|
@@ -9,7 +9,8 @@ export const zh = {
|
|
|
9
9
|
'mode.tts': '文本转语音',
|
|
10
10
|
'mode.music': '音乐生成',
|
|
11
11
|
'mode.sfx': '音效生成',
|
|
12
|
-
'
|
|
12
|
+
'mode.voiceDesign': '音色设计',
|
|
13
|
+
'prompt.placeholder': '输入文本、音乐/音效描述,或音色设计描述…',
|
|
13
14
|
'prompt.required': '请输入文本或提示词',
|
|
14
15
|
'model.label': '模型 / 音色',
|
|
15
16
|
'voice.label': '音色',
|
|
@@ -75,7 +76,8 @@ export const en: Record<AudioGenKey, string> = {
|
|
|
75
76
|
'mode.tts': 'Text to speech',
|
|
76
77
|
'mode.music': 'Music',
|
|
77
78
|
'mode.sfx': 'Sound effects',
|
|
78
|
-
'
|
|
79
|
+
'mode.voiceDesign': 'Voice design',
|
|
80
|
+
'prompt.placeholder': 'Text to speak, or a music/SFX/voice-design description…',
|
|
79
81
|
'prompt.required': 'Prompt or text is required',
|
|
80
82
|
'model.label': 'Model / voice',
|
|
81
83
|
'voice.label': 'Voice',
|