dsh-audiogen 0.4.0 → 0.4.2

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/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.0",
4
+ "version": "0.4.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: dsh-audiogen-voice-design
3
+ description: DSH AI 音频插件(dsh-audiogen)的音色设计技能:调用 generate_audio(mode=voice_design) 并指定厂商/渠道——MiniMax(POST /v1/voice_design,prompt + preview_text)或 ElevenLabs(POST /v1/text-to-voice/design,voice_description + 试听文本 100-1000 字符,过短自动生成;返回 previews[].audio_base_64 与 generated_voice_id 供后续 TTS 复用)。
4
+ whenToUse: 用户请求设计/创建新音色、音色试听,或触发 /audio:design 时使用。
5
+ ---
1
6
  # 音色/音效设计
2
7
 
3
8
  ## 触发
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: dsh-audiogen-music
3
+ description: DSH AI 音频插件(dsh-audiogen)的音乐生成技能:调用 generate_audio(mode=music);覆盖 MiniMax(music-3.0/2.6/cover,lyrics 歌词、is_instrumental 纯音乐、audio_setting 采样率 16000-44100/码率 32000-256000/格式 mp3-wav-pcm、时长)、ElevenLabs(/v1/music,music_v2,时长 3-600s、lyrics_text、force_instrumental)与 Stability(stable-audio 2/2.5/3,官方 v2beta 或 OpenAI 兼容 /v1/audio/speech 双通道,seed/steps/cfg_scale/duration)。
4
+ whenToUse: 用户请求生成音乐、配乐、BGM、纯音乐、歌曲,或触发 /audio:music 时使用;MiniMax 未给歌词且未要求纯音乐时,先补一段歌词或设置 is_instrumental。
5
+ ---
1
6
  # 音乐生成
2
7
 
3
8
  ## 触发
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: dsh-audiogen-sfx
3
+ description: DSH AI 音频插件(dsh-audiogen)的音效生成技能:调用 generate_audio(mode=sfx);覆盖 ElevenLabs(/v1/sound-generation,eleven_text_to_sound_v2,loop 无缝循环、prompt_influence 0-1、duration_seconds 0.5-30)与 MiniMax、Stability 等渠道的对应字段与常见错误处理。
4
+ whenToUse: 用户请求生成音效、提示音、环境音、UI 音,或触发 /audio:sfx 时使用。
5
+ ---
1
6
  # 音效生成
2
7
 
3
8
  ## 触发
@@ -1,3 +1,8 @@
1
+ ---
2
+ name: dsh-audiogen-tts
3
+ description: DSH AI 音频插件(dsh-audiogen)的 TTS 文本转语音技能:先确认渠道/模型/音色,再调用 generate_audio(mode=tts);包含 MiniMax 官方 t2a_v2 全字段(语速/音量/音调/情绪/采样率/码率/声道/发音词典/字幕/变声/双音色混合)、ElevenLabs 与 Stable Audio 的对应参数说明,以及常见错误(voice-required、网关 404 Invalid URL 等)的处理。
4
+ whenToUse: 用户提出朗读、配音、语音合成、TTS,或触发 /audio:tts 时使用;MiniMax 必须提供音色 voice_id。
5
+ ---
1
6
  # TTS 文本转语音
2
7
 
3
8
  ## 触发
@@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import type { AudioChannel } from './audio-engine.ts'
11
11
  import { generateAudio, AudioGenError } from './audio-engine.ts'
12
+ import type { GenerationBudget } from './audio-scheduler.ts'
12
13
  import { appendHistory, saveAudioFile, saveToLibrary, listLibrary } from './audio-store.ts'
13
14
  import type { AudioMode, GenerateAudioRequest, LibraryType } from './protocol.ts'
14
15
 
@@ -18,6 +19,8 @@ export interface AgentAudioToolConfig {
18
19
  channels: AudioChannel[]
19
20
  defaultChannelId: string
20
21
  autoSaveToLibrary: boolean
22
+ /** 全局并发闸门(与面板路由共享「最大并发生成数」)。 */
23
+ budget?: GenerationBudget
21
24
  }
22
25
 
23
26
  interface AgentAudioRef {
@@ -33,6 +36,14 @@ interface SavedAudioRef extends AgentAudioRef {
33
36
  file: string
34
37
  }
35
38
 
39
+ /** Per-model group in a multi-model comparison result. */
40
+ interface AgentAudioGroup {
41
+ model: string
42
+ audio: AgentAudioRef[]
43
+ resources?: string[]
44
+ error?: string
45
+ }
46
+
36
47
  interface AgentAudioResult {
37
48
  status: string
38
49
  message: string
@@ -41,6 +52,8 @@ interface AgentAudioResult {
41
52
  audio: AgentAudioRef[]
42
53
  /** Resource-library entry ids when the audio was saved to the library. */
43
54
  resources?: string[]
55
+ /** Per-model results when several models were generated with the same prompt. */
56
+ groups?: AgentAudioGroup[]
44
57
  error?: string
45
58
  }
46
59
 
@@ -56,6 +69,17 @@ const audioRefSchema = {
56
69
  },
57
70
  } as const
58
71
 
72
+ const groupSchema = {
73
+ type: 'object',
74
+ additionalProperties: false,
75
+ properties: {
76
+ model: { type: 'string', required: true },
77
+ audio: { type: 'array', required: true, items: audioRefSchema },
78
+ resources: { type: 'array', items: { type: 'string' } },
79
+ error: { type: 'string' },
80
+ },
81
+ } as const
82
+
59
83
  const resultSchema = {
60
84
  type: 'object',
61
85
  additionalProperties: false,
@@ -66,6 +90,7 @@ const resultSchema = {
66
90
  model: { type: 'string', required: true },
67
91
  audio: { type: 'array', required: true, items: audioRefSchema },
68
92
  resources: { type: 'array', items: { type: 'string' } },
93
+ groups: { type: 'array', items: groupSchema },
69
94
  error: { type: 'string' },
70
95
  },
71
96
  } as const
@@ -120,6 +145,16 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
120
145
  prompt: { type: 'string', required: true, description: 'For tts, the text to speak. For music/sfx, a descriptive prompt.' },
121
146
  mode: { type: 'string', enum: ['tts', 'music', 'sfx', 'voice_design'], description: 'Generation mode. Defaults to tts.' },
122
147
  model: { type: 'string', description: 'One of the configured audio models/voices. Defaults to the first configured model.' },
148
+ models: {
149
+ type: 'array',
150
+ items: { type: 'string' },
151
+ description: 'Optional: several configured model aliases to generate the SAME prompt with each one, sequentially, for comparison (e.g. ["speech-2.8-hd","speech-2.6-hd"]). Cannot be combined with model; when present, models wins.',
152
+ },
153
+ model_params: {
154
+ type: 'object',
155
+ additionalProperties: true,
156
+ description: 'Optional per-model parameter overrides used with "models" (automatic by default = all models share the global params). Keys are model aliases; values are partial param objects using the same param names (format, duration, voice, speed, emotion, vol, pitch, sample_rate, bitrate, lyrics, is_instrumental, loop, prompt_influence, seed, steps, cfg_scale, subtitle_enable, aigc_watermark, language_boost, pronunciation_tone, voice_modify, timbre_weights). Unset fields fall back to the global values.',
157
+ },
123
158
  voice: { type: 'string', description: 'Optional voice id/name for TTS providers. Required for MiniMax TTS (e.g. male-qn-qingse, female-shaonv); fetch the account voices in Settings > Plugins > AI Audio.' },
124
159
  preview_text: { type: 'string', description: 'Optional preview text for voice_design.' },
125
160
  speed: { type: 'number', description: 'Optional speaking rate / speed multiplier where supported. MiniMax range 0.5-2.0 (default 1).' },
@@ -163,10 +198,9 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
163
198
  type: 'object',
164
199
  additionalProperties: false,
165
200
  properties: {
166
- voice_id: { type: 'string' },
167
- weight: { type: 'integer' },
201
+ voice_id: { type: 'string', required: true },
202
+ weight: { type: 'integer', required: true },
168
203
  },
169
- required: ['voice_id', 'weight'],
170
204
  },
171
205
  description: 'MiniMax TTS dual-voice blend weights (timbre_weights).',
172
206
  },
@@ -185,170 +219,252 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
185
219
  async execute(args, exec) {
186
220
  const config = resolve()
187
221
  ensureConfigured(config)
188
- const mode = args.mode === 'music' ? 'music' : args.mode === 'sfx' ? 'sfx' : args.mode === 'voice_design' ? 'voice_design' : 'tts'
189
- const picked = mode === 'voice_design'
190
- ? (() => {
191
- const usable = config.channels.filter(channel => channel.apiUrl.trim() !== '' && channel.apiKey.trim() !== '')
192
- const target = usable.find(channel => channel.id === config.defaultChannelId) ?? usable[0]
193
- if (target === undefined) throw new AudioGenError('No usable audio channel is configured for voice design.', 'no-channel-available')
194
- return { channel: target, alias: '', upstream: '' }
195
- })()
196
- : resolveModel(config, args.model)
197
- const voiceModify = typeof args.voice_modify === 'object' && args.voice_modify !== null
198
- ? (() => {
199
- const raw = args.voice_modify as Record<string, unknown>
200
- const out: { pitch?: number; intensity?: number; timbre?: number; soundEffects?: string } = {}
201
- if (typeof raw.pitch === 'number') out.pitch = raw.pitch
202
- if (typeof raw.intensity === 'number') out.intensity = raw.intensity
203
- if (typeof raw.timbre === 'number') out.timbre = raw.timbre
204
- if (typeof raw.sound_effects === 'string' && raw.sound_effects.trim() !== '') out.soundEffects = raw.sound_effects.trim()
205
- return Object.keys(out).length > 0 ? out : undefined
206
- })()
207
- : undefined
208
- const timbreWeights = Array.isArray(args.timbre_weights)
209
- ? args.timbre_weights
210
- .filter((item): item is { voice_id: string; weight: number } => typeof item === 'object' && item !== null && typeof (item as { voice_id?: unknown }).voice_id === 'string' && typeof (item as { weight?: unknown }).weight === 'number')
211
- .map(item => ({ voiceId: (item.voice_id as string).trim(), weight: item.weight as number }))
212
- .filter(item => item.voiceId !== '')
213
- : undefined
214
- const request: GenerateAudioRequest = {
215
- mode,
216
- model: picked.alias,
217
- upstream: picked.upstream,
218
- channelId: picked.channel.id,
219
- channel: picked.channel.name,
220
- prompt: args.prompt.trim(),
221
- ...(typeof args.voice === 'string' && args.voice.trim() !== '' ? { voice: args.voice.trim() } : {}),
222
- ...(typeof args.preview_text === 'string' && args.preview_text.trim() !== '' ? { previewText: args.preview_text.trim() } : {}),
223
- ...(typeof args.speed === 'number' ? { speed: args.speed } : {}),
224
- ...(typeof args.duration === 'number' ? { duration: args.duration } : {}),
225
- ...(typeof args.lyrics === 'string' && args.lyrics.trim() !== '' ? { lyrics: args.lyrics.trim() } : {}),
226
- ...(typeof args.is_instrumental === 'boolean' ? { isInstrumental: args.is_instrumental } : {}),
227
- ...(typeof args.loop === 'boolean' ? { loop: args.loop } : {}),
228
- ...(typeof args.prompt_influence === 'number' && Number.isFinite(args.prompt_influence) ? { promptInfluence: args.prompt_influence } : {}),
229
- ...(typeof args.seed === 'number' && Number.isFinite(args.seed) ? { seed: args.seed } : {}),
230
- ...(typeof args.steps === 'number' && Number.isFinite(args.steps) ? { steps: args.steps } : {}),
231
- ...(typeof args.cfg_scale === 'number' && Number.isFinite(args.cfg_scale) ? { cfgScale: args.cfg_scale } : {}),
232
- ...(typeof args.format === 'string' && args.format.trim() !== '' ? { format: args.format.trim() } : {}),
233
- // ---- MiniMax TTS 专属字段 ----
234
- ...(typeof args.emotion === 'string' && args.emotion.trim() !== '' ? { emotion: args.emotion.trim() } : {}),
235
- ...(typeof args.vol === 'number' && Number.isFinite(args.vol) ? { vol: args.vol } : {}),
236
- ...(typeof args.pitch === 'number' && Number.isFinite(args.pitch) ? { pitch: args.pitch } : {}),
237
- ...(typeof args.text_normalization === 'boolean' ? { textNormalization: args.text_normalization } : {}),
238
- ...(typeof args.latex_read === 'boolean' ? { latexRead: args.latex_read } : {}),
239
- ...(Array.isArray(args.pronunciation_tone) && args.pronunciation_tone.length > 0
240
- ? { pronunciationTone: args.pronunciation_tone.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map((item: string) => item.trim()) }
241
- : {}),
242
- ...(typeof args.sample_rate === 'number' && Number.isFinite(args.sample_rate) ? { sampleRate: args.sample_rate } : {}),
243
- ...(typeof args.bitrate === 'number' && Number.isFinite(args.bitrate) ? { bitrate: args.bitrate } : {}),
244
- ...(typeof args.channel === 'number' && Number.isFinite(args.channel) ? { audioChannel: args.channel } : {}),
245
- ...(typeof args.force_cbr === 'boolean' ? { forceCbr: args.force_cbr } : {}),
246
- ...(typeof args.subtitle_enable === 'boolean' ? { subtitleEnable: args.subtitle_enable } : {}),
247
- ...(typeof args.aigc_watermark === 'boolean' ? { aigcWatermark: args.aigc_watermark } : {}),
248
- ...(typeof args.language_boost === 'string' && args.language_boost.trim() !== '' ? { languageBoost: args.language_boost.trim() } : {}),
249
- ...(voiceModify !== undefined ? { voiceModify } : {}),
250
- ...(timbreWeights !== undefined && timbreWeights.length > 0 ? { timbreWeights } : {}),
222
+ const mode: AudioMode = args.mode === 'music' ? 'music' : args.mode === 'sfx' ? 'sfx' : args.mode === 'voice_design' ? 'voice_design' : 'tts'
223
+ /** 把生成参数(snake_case 入参或 model_params 片段)映射为请求字段。 */
224
+ const mapParams = (raw: Record<string, unknown>): Partial<GenerateAudioRequest> => {
225
+ const voiceModify = typeof raw.voice_modify === 'object' && raw.voice_modify !== null
226
+ ? (() => {
227
+ const src = raw.voice_modify as Record<string, unknown>
228
+ const out: { pitch?: number; intensity?: number; timbre?: number; soundEffects?: string } = {}
229
+ if (typeof src.pitch === 'number') out.pitch = src.pitch
230
+ if (typeof src.intensity === 'number') out.intensity = src.intensity
231
+ if (typeof src.timbre === 'number') out.timbre = src.timbre
232
+ if (typeof src.sound_effects === 'string' && src.sound_effects.trim() !== '') out.soundEffects = src.sound_effects.trim()
233
+ return Object.keys(out).length > 0 ? out : undefined
234
+ })()
235
+ : undefined
236
+ const timbreWeights = Array.isArray(raw.timbre_weights)
237
+ ? raw.timbre_weights
238
+ .filter((item): item is { voice_id: string; weight: number } => typeof item === 'object' && item !== null && typeof (item as { voice_id?: unknown }).voice_id === 'string' && typeof (item as { weight?: unknown }).weight === 'number')
239
+ .map(item => ({ voiceId: (item.voice_id as string).trim(), weight: item.weight as number }))
240
+ .filter(item => item.voiceId !== '')
241
+ : undefined
242
+ const stringOrEmpty = (key: string): string | undefined => {
243
+ const value = raw[key]
244
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
245
+ }
246
+ const finiteOrUndefined = (key: string): number | undefined => {
247
+ const value = raw[key]
248
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
249
+ }
250
+ return {
251
+ ...(stringOrEmpty('voice') !== undefined ? { voice: stringOrEmpty('voice')! } : {}),
252
+ ...(stringOrEmpty('preview_text') !== undefined ? { previewText: stringOrEmpty('preview_text')! } : {}),
253
+ ...(finiteOrUndefined('speed') !== undefined ? { speed: finiteOrUndefined('speed')! } : {}),
254
+ ...(finiteOrUndefined('duration') !== undefined ? { duration: finiteOrUndefined('duration')! } : {}),
255
+ ...(stringOrEmpty('lyrics') !== undefined ? { lyrics: stringOrEmpty('lyrics')! } : {}),
256
+ ...(typeof raw.is_instrumental === 'boolean' ? { isInstrumental: raw.is_instrumental } : {}),
257
+ ...(typeof raw.loop === 'boolean' ? { loop: raw.loop } : {}),
258
+ ...(finiteOrUndefined('prompt_influence') !== undefined ? { promptInfluence: finiteOrUndefined('prompt_influence')! } : {}),
259
+ ...(finiteOrUndefined('seed') !== undefined ? { seed: finiteOrUndefined('seed')! } : {}),
260
+ ...(finiteOrUndefined('steps') !== undefined ? { steps: finiteOrUndefined('steps')! } : {}),
261
+ ...(finiteOrUndefined('cfg_scale') !== undefined ? { cfgScale: finiteOrUndefined('cfg_scale')! } : {}),
262
+ ...(stringOrEmpty('format') !== undefined ? { format: stringOrEmpty('format')! } : {}),
263
+ // ---- MiniMax / ElevenLabs / Stability 专属字段 ----
264
+ ...(stringOrEmpty('emotion') !== undefined ? { emotion: stringOrEmpty('emotion')! } : {}),
265
+ ...(finiteOrUndefined('vol') !== undefined ? { vol: finiteOrUndefined('vol')! } : {}),
266
+ ...(finiteOrUndefined('pitch') !== undefined ? { pitch: finiteOrUndefined('pitch')! } : {}),
267
+ ...(typeof raw.text_normalization === 'boolean' ? { textNormalization: raw.text_normalization } : {}),
268
+ ...(typeof raw.latex_read === 'boolean' ? { latexRead: raw.latex_read } : {}),
269
+ ...(Array.isArray(raw.pronunciation_tone) && raw.pronunciation_tone.length > 0
270
+ ? { pronunciationTone: raw.pronunciation_tone.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map((item: string) => item.trim()) }
271
+ : {}),
272
+ ...(finiteOrUndefined('sample_rate') !== undefined ? { sampleRate: finiteOrUndefined('sample_rate')! } : {}),
273
+ ...(finiteOrUndefined('bitrate') !== undefined ? { bitrate: finiteOrUndefined('bitrate')! } : {}),
274
+ ...(finiteOrUndefined('channel') !== undefined ? { audioChannel: finiteOrUndefined('channel')! } : {}),
275
+ ...(typeof raw.force_cbr === 'boolean' ? { forceCbr: raw.force_cbr } : {}),
276
+ ...(typeof raw.subtitle_enable === 'boolean' ? { subtitleEnable: raw.subtitle_enable } : {}),
277
+ ...(typeof raw.aigc_watermark === 'boolean' ? { aigcWatermark: raw.aigc_watermark } : {}),
278
+ ...(stringOrEmpty('language_boost') !== undefined ? { languageBoost: stringOrEmpty('language_boost')! } : {}),
279
+ ...(voiceModify !== undefined ? { voiceModify } : {}),
280
+ ...(timbreWeights !== undefined && timbreWeights.length > 0 ? { timbreWeights } : {}),
281
+ }
251
282
  }
252
- try {
253
- const outputs = await generateAudio(picked.channel, request, exec.signal)
254
- const audio: AgentAudioRef[] = []
255
- const saved: SavedAudioRef[] = []
256
- for (const [index, output] of outputs.entries()) {
257
- const stored = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
258
- saved.push({
259
- id: stored.id,
260
- url: `/api/dsh-audiogen/audio/${encodeURIComponent(stored.file)}`,
261
- file: stored.file,
262
- mime: stored.mime,
263
- bytes: stored.bytes,
264
- ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
265
- })
266
- audio.push({
267
- id: stored.id,
268
- url: `/api/dsh-audiogen/audio/${encodeURIComponent(stored.file)}`,
269
- mime: stored.mime,
270
- bytes: stored.bytes,
271
- ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
272
- })
283
+ const buildRequest = (picked: { channel: AudioChannel; alias: string; upstream: string }): GenerateAudioRequest => {
284
+ const base = mapParams(args as unknown as Record<string, unknown>)
285
+ // 每模型参数覆盖(model_params[alias]);缺省 = 自动沿用全局配置
286
+ let override: Partial<GenerateAudioRequest> = {}
287
+ if (typeof args.model_params === 'object' && args.model_params !== null) {
288
+ const perModel = (args.model_params as Record<string, unknown>)[picked.alias]
289
+ if (typeof perModel === 'object' && perModel !== null) override = mapParams(perModel as Record<string, unknown>)
273
290
  }
291
+ return {
292
+ mode,
293
+ model: picked.alias,
294
+ upstream: picked.upstream,
295
+ channelId: picked.channel.id,
296
+ channel: picked.channel.name,
297
+ prompt: typeof args.prompt === 'string' ? args.prompt.trim() : '',
298
+ ...base,
299
+ ...override,
300
+ }
301
+ }
302
+ /** 单模型执行:生成 + 保存文件 + 历史 + 可选资源库;错误收敛为分组结果。 */
303
+ const runOne = async (picked: { channel: AudioChannel; alias: string; upstream: string }): Promise<AgentAudioGroup> => {
304
+ const request = buildRequest(picked)
274
305
  try {
275
- await appendHistory({
276
- id: randomUUID(),
277
- createdAt: Date.now(),
278
- mode: request.mode,
279
- model: picked.alias,
280
- prompt: request.prompt,
281
- ...(request.voice === undefined ? {} : { voice: request.voice }),
282
- ...(request.speed === undefined ? {} : { speed: request.speed }),
283
- ...(request.duration === undefined ? {} : { duration: request.duration }),
284
- ...(request.format === undefined ? {} : { format: request.format }),
285
- audio: outputs.map((output, index) => ({
286
- id: saved[index]!.id,
287
- file: saved[index]!.file,
288
- b64: Buffer.from(output.data).toString('base64'),
289
- mime: saved[index]!.mime,
290
- bytes: saved[index]!.bytes,
291
- url: saved[index]!.url,
306
+ // 与面板路由共享全局并发闸门(限流时排队;取消时立即出队)。
307
+ const release = await (config.budget?.acquire(exec.signal) ?? Promise.resolve(() => { /* 默认不限制 */ }))
308
+ let outputs
309
+ try {
310
+ outputs = await generateAudio(picked.channel, request, exec.signal)
311
+ } finally {
312
+ release()
313
+ }
314
+ const audio: AgentAudioRef[] = []
315
+ const saved: SavedAudioRef[] = []
316
+ for (const [index, output] of outputs.entries()) {
317
+ const stored = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
318
+ saved.push({
319
+ id: stored.id,
320
+ url: `/api/dsh-audiogen/audio/${encodeURIComponent(stored.file)}`,
321
+ file: stored.file,
322
+ mime: stored.mime,
323
+ bytes: stored.bytes,
292
324
  ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
293
- })),
294
- channelId: picked.channel.id,
295
- channel: picked.channel.name,
296
- params: { ...request },
297
- })
298
- } catch {
299
- // History is best-effort and must not fail the agent tool.
300
- }
301
- // ---- 资源库保存:显式参数优先;设置自动入库时可用 false 跳过 ----
302
- const wantSave = args.save_to_library === true || (config.autoSaveToLibrary && args.save_to_library !== false)
303
- let resources: string[] | undefined
304
- if (wantSave) {
325
+ })
326
+ audio.push({
327
+ id: stored.id,
328
+ url: `/api/dsh-audiogen/audio/${encodeURIComponent(stored.file)}`,
329
+ mime: stored.mime,
330
+ bytes: stored.bytes,
331
+ ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
332
+ })
333
+ }
305
334
  try {
306
- const entry = await saveToLibrary({
307
- audioFiles: saved.map(item => ({
308
- id: item.id,
309
- file: item.file,
310
- mime: item.mime,
311
- ...(item.voiceId === undefined ? {} : { voiceId: item.voiceId }),
335
+ await appendHistory({
336
+ id: randomUUID(),
337
+ createdAt: Date.now(),
338
+ mode: request.mode,
339
+ model: picked.alias,
340
+ prompt: request.prompt,
341
+ ...(request.voice === undefined ? {} : { voice: request.voice }),
342
+ ...(request.speed === undefined ? {} : { speed: request.speed }),
343
+ ...(request.duration === undefined ? {} : { duration: request.duration }),
344
+ ...(request.format === undefined ? {} : { format: request.format }),
345
+ audio: outputs.map((output, index) => ({
346
+ id: saved[index]!.id,
347
+ file: saved[index]!.file,
348
+ b64: Buffer.from(output.data).toString('base64'),
349
+ mime: saved[index]!.mime,
350
+ bytes: saved[index]!.bytes,
351
+ url: saved[index]!.url,
352
+ ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
312
353
  })),
313
- type: libraryTypeOf(request.mode, args.library_type),
314
- ...(typeof args.library_name === 'string' && args.library_name.trim() !== '' ? { name: args.library_name.trim() } : {}),
315
- ...(Array.isArray(args.library_tags) ? { tags: args.library_tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '').map(tag => tag.trim()) } : {}),
316
- provenance: {
317
- mode: request.mode,
318
- prompt: request.prompt,
319
- channel: picked.channel.name,
320
- channelId: picked.channel.id,
321
- apiUrl: picked.channel.apiUrl,
322
- model: picked.alias,
323
- upstream: picked.upstream,
324
- ...(request.voice === undefined ? {} : { voice: request.voice }),
325
- params: { ...request },
326
- },
354
+ channelId: picked.channel.id,
355
+ channel: picked.channel.name,
356
+ params: { ...request },
327
357
  })
328
- resources = [entry.id]
329
358
  } catch {
330
- // library-save is best-effort; generation already succeeded.
359
+ // History is best-effort and must not fail the agent tool.
360
+ }
361
+ // ---- 资源库保存:显式参数优先;设置自动入库时可用 false 跳过 ----
362
+ const wantSave = args.save_to_library === true || (config.autoSaveToLibrary && args.save_to_library !== false)
363
+ let resources: string[] | undefined
364
+ if (wantSave) {
365
+ try {
366
+ const entry = await saveToLibrary({
367
+ audioFiles: saved.map(item => ({
368
+ id: item.id,
369
+ file: item.file,
370
+ mime: item.mime,
371
+ ...(item.voiceId === undefined ? {} : { voiceId: item.voiceId }),
372
+ })),
373
+ type: libraryTypeOf(request.mode, args.library_type),
374
+ ...(typeof args.library_name === 'string' && args.library_name.trim() !== '' ? { name: args.library_name.trim() } : {}),
375
+ ...(Array.isArray(args.library_tags) ? { tags: args.library_tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '').map(tag => tag.trim()) } : {}),
376
+ provenance: {
377
+ mode: request.mode,
378
+ prompt: request.prompt,
379
+ channel: picked.channel.name,
380
+ channelId: picked.channel.id,
381
+ apiUrl: picked.channel.apiUrl,
382
+ model: picked.alias,
383
+ upstream: picked.upstream,
384
+ ...(request.voice === undefined ? {} : { voice: request.voice }),
385
+ params: { ...request },
386
+ },
387
+ })
388
+ resources = [entry.id]
389
+ } catch {
390
+ // library-save is best-effort; generation already succeeded.
391
+ }
392
+ }
393
+ return {
394
+ model: picked.alias,
395
+ audio,
396
+ ...(resources === undefined ? {} : { resources }),
397
+ }
398
+ } catch (error) {
399
+ if (exec.signal?.aborted === true) throw error
400
+ return {
401
+ model: picked.alias,
402
+ audio: [],
403
+ error: error instanceof Error ? error.message : String(error),
331
404
  }
332
405
  }
406
+ }
407
+ // ---- 多模型对比:同一 prompt 依次生成 ----
408
+ const requestedModels = Array.isArray(args.models)
409
+ ? [...new Set(args.models.filter((item: unknown): item is string => typeof item === 'string' && item.trim() !== '').map((item: string) => item.trim()))]
410
+ : []
411
+ if (requestedModels.length > 0 && mode !== 'voice_design') {
412
+ const groups: AgentAudioGroup[] = []
413
+ let succeeded = 0
414
+ for (const alias of requestedModels) {
415
+ let picked
416
+ try {
417
+ picked = resolveModel(config, alias)
418
+ } catch (error) {
419
+ groups.push({ model: alias, audio: [], error: error instanceof Error ? error.message : String(error) })
420
+ continue
421
+ }
422
+ const group = await runOne(picked)
423
+ groups.push(group)
424
+ if (group.error === undefined) succeeded++
425
+ }
333
426
  return {
334
- status: 'completed',
335
- message: 'Audio generation completed. The audio files can be played/downloaded from the returned URLs.',
336
- mode: request.mode,
337
- model: picked.alias,
338
- audio,
339
- ...(resources === undefined ? {} : { resources }),
427
+ status: succeeded > 0 ? 'completed' : 'failed',
428
+ message: succeeded > 0
429
+ ? `Generated ${succeeded}/${groups.length} model(s) with the same prompt for comparison. The audio files can be played/downloaded from the returned URLs.`
430
+ : 'All model generations failed.',
431
+ mode,
432
+ model: groups[0]?.model ?? requestedModels[0]!,
433
+ audio: groups.flatMap(group => group.audio),
434
+ groups,
435
+ ...(succeeded === 0
436
+ ? { error: groups.map(group => `${group.model}: ${group.error ?? ''}`).filter(item => !item.endsWith(': ')).join(';') }
437
+ : {}),
340
438
  }
341
- } catch (error) {
342
- if (exec.signal?.aborted === true) throw error
439
+ }
440
+ // ---- 单模型(含音色设计) ----
441
+ const picked = mode === 'voice_design'
442
+ ? (() => {
443
+ const usable = config.channels.filter(channel => channel.apiUrl.trim() !== '' && channel.apiKey.trim() !== '')
444
+ const target = usable.find(channel => channel.id === config.defaultChannelId) ?? usable[0]
445
+ if (target === undefined) throw new AudioGenError('No usable audio channel is configured for voice design.', 'no-channel-available')
446
+ return { channel: target, alias: '', upstream: '' }
447
+ })()
448
+ : resolveModel(config, args.model)
449
+ const one = await runOne(picked)
450
+ if (one.error !== undefined) {
343
451
  return {
344
452
  status: 'failed',
345
453
  message: 'Audio generation failed.',
346
- mode: request.mode,
347
- model: picked.alias,
454
+ mode,
455
+ model: one.model,
348
456
  audio: [],
349
- error: error instanceof Error ? error.message : String(error),
457
+ error: one.error,
350
458
  }
351
459
  }
460
+ return {
461
+ status: 'completed',
462
+ message: 'Audio generation completed. The audio files can be played/downloaded from the returned URLs.',
463
+ mode,
464
+ model: one.model,
465
+ audio: one.audio,
466
+ ...(one.resources === undefined ? {} : { resources: one.resources }),
467
+ }
352
468
  },
353
469
  }))
354
470
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Global generation budget: a FIFO semaphore shared by every upstream audio
3
+ * call (panel routes and the Agent tool). The limit comes from the plugin
4
+ * setting 「最大并发生成数」(default 5): a 3-model compare task takes 3 slots,
5
+ * other tasks queue or run concurrently up to the same cap.
6
+ *
7
+ * Acquire resolves with a release function once a slot is free; aborting the
8
+ * signal while queued rejects immediately (no slot is occupied).
9
+ */
10
+
11
+ export interface GenerationBudget {
12
+ /** Wait for a free slot; resolves with the release function. */
13
+ acquire(signal?: AbortSignal): Promise<() => void>
14
+ }
15
+
16
+ export function createGenerationBudget(limit: () => number): GenerationBudget {
17
+ let active = 0
18
+ const waiting: Array<{
19
+ resolve: (release: () => void) => void
20
+ reject: (reason: unknown) => void
21
+ signal?: AbortSignal
22
+ cleanup?: () => void
23
+ }> = []
24
+
25
+ const clampLimit = (): number => {
26
+ const raw = Number(limit())
27
+ if (!Number.isFinite(raw) || raw < 1) return 5
28
+ return Math.min(20, Math.floor(raw))
29
+ }
30
+
31
+ const pump = (): void => {
32
+ const max = clampLimit()
33
+ while (active < max && waiting.length > 0) {
34
+ const entry = waiting.shift()!
35
+ if (entry.signal?.aborted === true) {
36
+ entry.reject(new DOMException('The operation was aborted.', 'AbortError'))
37
+ continue
38
+ }
39
+ entry.cleanup?.()
40
+ active += 1
41
+ let released = false
42
+ entry.resolve(() => {
43
+ if (released) return
44
+ released = true
45
+ active = Math.max(0, active - 1)
46
+ pump()
47
+ })
48
+ }
49
+ }
50
+
51
+ const acquire = (signal?: AbortSignal): Promise<() => void> => new Promise<() => void>((resolve, reject) => {
52
+ const entry: { resolve: (release: () => void) => void; reject: (reason: unknown) => void; signal?: AbortSignal; cleanup?: () => void } = { resolve, reject, signal }
53
+ const onAbort = (): void => {
54
+ const index = waiting.indexOf(entry)
55
+ if (index < 0) return // 已在运行:由调用方的 signal 中断上游请求
56
+ waiting.splice(index, 1)
57
+ entry.cleanup = undefined
58
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
59
+ }
60
+ entry.cleanup = () => { signal?.removeEventListener('abort', onAbort) }
61
+ if (signal !== undefined) {
62
+ if (signal.aborted === true) {
63
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
64
+ return
65
+ }
66
+ signal.addEventListener('abort', onAbort, { once: true })
67
+ }
68
+ waiting.push(entry)
69
+ pump()
70
+ })
71
+
72
+ return { acquire }
73
+ }