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/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 sound effects. 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.",
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 picked = resolveModel(config, args.model);
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: args.mode === "music" ? "music" : args.mode === "sfx" ? "sfx" : "tts",
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.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -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 sound effects. 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.',
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 picked = resolveModel(config, args.model)
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: args.mode === 'music' ? 'music' : args.mode === 'sfx' ? 'sfx' : 'tts',
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,
@@ -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)
@@ -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 { GENERATE_API, HISTORY_API, type AudioMode, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
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(() => modelOptions.models
66
- .filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
67
- .map(entry => entry.alias), [modelOptions.models, mode])
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
- <label className={css.label}>
137
- <span>{tt('model.label')}</span>
138
- <select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
139
- {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
140
- {visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
141
- </select>
142
- </label>
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
- <label className={css.label}>
150
- <span>{tt('speed.label')}</span>
151
- <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" />
152
- </label>
153
- <label className={css.label}>
154
- <span>{tt('duration.label')}</span>
155
- <input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
156
- </label>
157
- <label className={css.label}>
158
- <span>{tt('format.label')}</span>
159
- <select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
160
- <option value="mp3">mp3</option>
161
- <option value="wav">wav</option>
162
- <option value="flac">flac</option>
163
- <option value="ogg">ogg</option>
164
- <option value="pcm">pcm</option>
165
- </select>
166
- </label>
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>
@@ -9,7 +9,8 @@ export const zh = {
9
9
  'mode.tts': '文本转语音',
10
10
  'mode.music': '音乐生成',
11
11
  'mode.sfx': '音效生成',
12
- 'prompt.placeholder': '输入要朗读的文本,或描述想生成的音乐 / 音效…',
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
- 'prompt.placeholder': 'Text to speak, or a description of the music / sound effect…',
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',