dsh-audiogen 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/lib/client.js +2368 -630
- package/lib/client.js.map +1 -1
- package/lib/index.js +1022 -68
- package/package.json +1 -1
- package/skills/design/SKILL.md +7 -1
- package/skills/music/SKILL.md +37 -0
- package/skills/sfx/SKILL.md +17 -3
- package/src/agent-audio-tools.ts +159 -14
- package/src/audio-engine.ts +284 -18
- package/src/audio-presets.ts +10 -4
- package/src/audio-store.ts +251 -3
- package/src/client/AudioGenPanel.tsx +59 -334
- package/src/client/SettingsCard.tsx +9 -0
- package/src/client/api.ts +45 -2
- package/src/client/audio-panel.module.css +652 -79
- package/src/client/audio-player.tsx +87 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/library-save-dialog.tsx +173 -0
- package/src/client/library-view.tsx +511 -0
- package/src/client/library.module.css +686 -0
- package/src/client/locales.ts +6 -0
- package/src/client/settings-scope.ts +2 -0
- package/src/client/studio-view.tsx +560 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +130 -1
- package/src/routes.ts +247 -8
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import z from "schemastery";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
@@ -34,6 +34,21 @@ const HISTORY_API = {
|
|
|
34
34
|
clear: "/api/dsh-audiogen/history/clear",
|
|
35
35
|
audio: "/api/dsh-audiogen/history/audio"
|
|
36
36
|
};
|
|
37
|
+
/** Host-persisted resource-library routes. */
|
|
38
|
+
const LIBRARY_API = {
|
|
39
|
+
list: "/api/dsh-audiogen/library/list",
|
|
40
|
+
save: "/api/dsh-audiogen/library/save",
|
|
41
|
+
update: "/api/dsh-audiogen/library/update",
|
|
42
|
+
remove: "/api/dsh-audiogen/library/remove",
|
|
43
|
+
audio: "/api/dsh-audiogen/library/audio"
|
|
44
|
+
};
|
|
45
|
+
/** All library types, for iteration and validation. */
|
|
46
|
+
const LIBRARY_TYPES = [
|
|
47
|
+
"voice",
|
|
48
|
+
"music",
|
|
49
|
+
"sfx",
|
|
50
|
+
"tts"
|
|
51
|
+
];
|
|
37
52
|
//#endregion
|
|
38
53
|
//#region src/audio-engine.ts
|
|
39
54
|
/** An audio generation failure with a user-presentable message. */
|
|
@@ -79,7 +94,12 @@ function detectAudioMime(data) {
|
|
|
79
94
|
}
|
|
80
95
|
function mimeFromContentType(value) {
|
|
81
96
|
if (value === null || value === "") return void 0;
|
|
82
|
-
|
|
97
|
+
const parts = value.split(";");
|
|
98
|
+
for (const part of parts.slice(1)) {
|
|
99
|
+
const match = /^\s*type=([^;\s]+)/i.exec(part);
|
|
100
|
+
if (match !== null) return match[1].trim().toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
return parts[0].trim().toLowerCase();
|
|
83
103
|
}
|
|
84
104
|
function audioMime(data, contentType) {
|
|
85
105
|
return detectAudioMime(data) ?? mimeFromContentType(contentType) ?? "audio/mpeg";
|
|
@@ -250,6 +270,97 @@ async function openAITTS(channel, request, signal) {
|
|
|
250
270
|
async function elevenLabs(channel, request, signal) {
|
|
251
271
|
const base = endpointBase(channel.apiUrl);
|
|
252
272
|
const model = (request.upstream ?? request.model) || "eleven_multilingual_v2";
|
|
273
|
+
const headers = {
|
|
274
|
+
"xi-api-key": channel.apiKey.trim(),
|
|
275
|
+
authorization: `Bearer ${channel.apiKey.trim()}`,
|
|
276
|
+
"content-type": "application/json",
|
|
277
|
+
accept: "audio/mpeg, application/json"
|
|
278
|
+
};
|
|
279
|
+
if (request.mode === "voice_design") {
|
|
280
|
+
const endpoint = `${base}/text-to-voice/design`;
|
|
281
|
+
const previewText = request.previewText?.trim() ?? "";
|
|
282
|
+
const body = {
|
|
283
|
+
voice_description: request.prompt,
|
|
284
|
+
...previewText.length >= 100 ? { text: previewText } : { auto_generate_text: true }
|
|
285
|
+
};
|
|
286
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
redirect: "error",
|
|
289
|
+
headers,
|
|
290
|
+
body: JSON.stringify(body),
|
|
291
|
+
signal
|
|
292
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
const detail = await response.text().catch(() => "");
|
|
295
|
+
throw new AudioGenError(`ElevenLabs voice design API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
|
|
296
|
+
}
|
|
297
|
+
const previews = (await response.json()).previews ?? [];
|
|
298
|
+
if (previews.length === 0) throw new AudioGenError("ElevenLabs voice design returned no previews", "audio-empty-result");
|
|
299
|
+
const outputs = [];
|
|
300
|
+
for (const preview of previews) {
|
|
301
|
+
const encoded = preview.audio_base_64?.trim() ?? "";
|
|
302
|
+
if (encoded === "") continue;
|
|
303
|
+
const data = new Uint8Array(Buffer.from(encoded, "base64"));
|
|
304
|
+
outputs.push({
|
|
305
|
+
data,
|
|
306
|
+
mime: preview.media_type ?? "audio/mpeg",
|
|
307
|
+
...preview.generated_voice_id === void 0 || preview.generated_voice_id === "" ? {} : { voiceId: preview.generated_voice_id }
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (outputs.length === 0) throw new AudioGenError("ElevenLabs voice design returned no audio", "audio-empty-result");
|
|
311
|
+
return outputs;
|
|
312
|
+
}
|
|
313
|
+
if (request.mode === "music") {
|
|
314
|
+
const endpoint = `${base}/music`;
|
|
315
|
+
const body = {
|
|
316
|
+
model_id: (request.upstream ?? request.model) || "music_v1",
|
|
317
|
+
prompt: request.prompt,
|
|
318
|
+
...request.duration !== void 0 && Number.isFinite(request.duration) ? { music_length_ms: Math.round(Math.min(6e5, Math.max(3e3, request.duration * 1e3))) } : {},
|
|
319
|
+
...request.lyrics !== void 0 && request.lyrics.trim() !== "" ? { lyrics_text: request.lyrics.trim() } : {},
|
|
320
|
+
...request.isInstrumental !== void 0 ? { force_instrumental: request.isInstrumental } : {}
|
|
321
|
+
};
|
|
322
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
redirect: "follow",
|
|
325
|
+
headers,
|
|
326
|
+
body: JSON.stringify(body),
|
|
327
|
+
signal
|
|
328
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const detail = await response.text().catch(() => "");
|
|
331
|
+
throw new AudioGenError(`ElevenLabs music API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
|
|
332
|
+
}
|
|
333
|
+
return normalizeAudioResponse(response, {
|
|
334
|
+
apiKey: channel.apiKey,
|
|
335
|
+
fallbackMime: "audio/mpeg"
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (request.mode === "sfx") {
|
|
339
|
+
const endpoint = `${base}/sound-generation`;
|
|
340
|
+
const sfxModel = (request.upstream ?? request.model) || "eleven_text_to_sound_v2";
|
|
341
|
+
const body = {
|
|
342
|
+
text: request.prompt,
|
|
343
|
+
model_id: sfxModel,
|
|
344
|
+
...request.duration !== void 0 && Number.isFinite(request.duration) ? { duration_seconds: Math.min(30, Math.max(.5, request.duration)) } : {},
|
|
345
|
+
...request.loop !== void 0 ? { loop: request.loop } : {},
|
|
346
|
+
...request.promptInfluence !== void 0 && Number.isFinite(request.promptInfluence) ? { prompt_influence: Math.min(1, Math.max(0, request.promptInfluence)) } : {}
|
|
347
|
+
};
|
|
348
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
redirect: "follow",
|
|
351
|
+
headers,
|
|
352
|
+
body: JSON.stringify(body),
|
|
353
|
+
signal
|
|
354
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
const detail = await response.text().catch(() => "");
|
|
357
|
+
throw new AudioGenError(`ElevenLabs sound effects API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
|
|
358
|
+
}
|
|
359
|
+
return normalizeAudioResponse(response, {
|
|
360
|
+
apiKey: channel.apiKey,
|
|
361
|
+
fallbackMime: "audio/mpeg"
|
|
362
|
+
});
|
|
363
|
+
}
|
|
253
364
|
const voiceId = (request.voice ?? request.model ?? model).trim();
|
|
254
365
|
const endpoint = `${base}/text-to-speech/${encodeURIComponent(voiceId)}`;
|
|
255
366
|
const body = {
|
|
@@ -266,11 +377,7 @@ async function elevenLabs(channel, request, signal) {
|
|
|
266
377
|
return normalizeAudioResponse(await fetchWithTimeout(endpoint, {
|
|
267
378
|
method: "POST",
|
|
268
379
|
redirect: "error",
|
|
269
|
-
headers
|
|
270
|
-
"xi-api-key": channel.apiKey.trim(),
|
|
271
|
-
"content-type": "application/json",
|
|
272
|
-
accept: "audio/mpeg, application/json"
|
|
273
|
-
},
|
|
380
|
+
headers,
|
|
274
381
|
body: JSON.stringify(body),
|
|
275
382
|
signal
|
|
276
383
|
}, UPSTREAM_TIMEOUT_MS), {
|
|
@@ -504,30 +611,155 @@ async function minimax(channel, request, signal) {
|
|
|
504
611
|
throw new AudioGenError(`MiniMax 渠道「${channel.name}」网关未提供原生 TTS 接口:POST ${endpoint} 返回 HTTP 404(Invalid URL,网关未路由 /v1/t2a_v2);已回退 OpenAI 兼容 ${minimaxApiBase(channel.apiUrl)}/audio/speech 仍失败:${detailText.slice(0, 300)}。请把渠道 API 地址配置为官方 https://api.minimaxi.com(配合 MiniMax 官方密钥),或确认网关已将 /v1/audio/speech 映射到 MiniMax 音色渠道。`, "audio-api-error");
|
|
505
612
|
}
|
|
506
613
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
614
|
+
/** Stability 内部信号:路由缺失(网关 404 Invalid URL),可切换另一协议重试。 */
|
|
615
|
+
var StabilityRouteMissError = class extends Error {};
|
|
616
|
+
/** 网关风格:apiUrl 形如 .../v1、.../v1/audio/speech 时优先 OpenAI 兼容 speech。 */
|
|
617
|
+
function stabilityGatewayStyle(channel) {
|
|
618
|
+
const url = channel.apiUrl.trim().toLowerCase();
|
|
619
|
+
return /\/v1(\/|$|\?)/.test(url) || /\/audio\/speech(\?|$)/.test(url);
|
|
620
|
+
}
|
|
621
|
+
function isStabilityRouteMiss(status, detail) {
|
|
622
|
+
return status === 404 && /invalid url|invalid_request_error/i.test(detail);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Stable Audio 官方 v2beta(multipart/form-data)。
|
|
626
|
+
* - stable-audio-3 → POST {base}/stable-audio/text-to-audio (202 异步 → GET /v2beta/audio/results/{id} 轮询)
|
|
627
|
+
* - stable-audio-2 / 2.5 → POST {base}/stable-audio-2/text-to-audio (200 同步返回音频/JSON base64)
|
|
628
|
+
* - 不同模型参数不同:stable-audio-3 steps 4-8、duration ≤380;2 steps 30-100、cfg_scale 默认 7;
|
|
629
|
+
* 2.5 steps 4-8、cfg_scale 默认 1;均支持 seed、output_format(hp3|wav)。
|
|
630
|
+
*/
|
|
631
|
+
async function stabilityNativeAudio(channel, request, signal) {
|
|
632
|
+
const rawBase = endpointBase(channel.apiUrl);
|
|
633
|
+
const model = (request.upstream ?? request.model) || "stable-audio-2.5";
|
|
634
|
+
const isV3 = /^stable-audio-3/i.test(model);
|
|
635
|
+
const isV2 = /^stable-audio-2(\.[05])?$/i.test(model) || /^stable-audio-2-/i.test(model);
|
|
636
|
+
const group = isV2 ? "stable-audio-2" : "stable-audio";
|
|
637
|
+
const base = /\/v2beta\/audio$/i.test(rawBase) ? rawBase : /\/v2beta$/i.test(rawBase) ? `${rawBase}/audio` : `${rawBase}/v2beta/audio`;
|
|
638
|
+
const endpoint = `${base}/${group}/text-to-audio`;
|
|
639
|
+
const form = new FormData();
|
|
640
|
+
form.set("prompt", request.prompt);
|
|
641
|
+
form.set("model", model);
|
|
642
|
+
if (request.duration !== void 0 && Number.isFinite(request.duration)) {
|
|
643
|
+
const maxDuration = isV3 ? 380 : 190;
|
|
644
|
+
form.set("duration", String(Math.min(maxDuration, Math.max(1, request.duration))));
|
|
645
|
+
}
|
|
646
|
+
if (request.seed !== void 0 && Number.isFinite(request.seed)) form.set("seed", String(Math.floor(Math.min(4294967294, Math.max(0, request.seed)))));
|
|
647
|
+
const format = request.format === "wav" ? "wav" : "mp3";
|
|
648
|
+
form.set("output_format", format);
|
|
649
|
+
if (request.steps !== void 0 && Number.isInteger(request.steps)) {
|
|
650
|
+
const minSteps = isV2 && !/2\.5/i.test(model) ? 30 : 4;
|
|
651
|
+
const maxSteps = isV2 && !/2\.5/i.test(model) ? 100 : 8;
|
|
652
|
+
form.set("steps", String(Math.min(maxSteps, Math.max(minSteps, request.steps))));
|
|
653
|
+
}
|
|
654
|
+
if (request.cfgScale !== void 0 && Number.isFinite(request.cfgScale)) form.set("cfg_scale", String(Math.min(25, Math.max(1, request.cfgScale))));
|
|
655
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
656
|
+
method: "POST",
|
|
657
|
+
redirect: "error",
|
|
658
|
+
headers: {
|
|
659
|
+
authorization: `Bearer ${channel.apiKey.trim()}`,
|
|
660
|
+
accept: "application/json"
|
|
661
|
+
},
|
|
662
|
+
body: form,
|
|
663
|
+
signal
|
|
664
|
+
}, isV3 ? 6e4 : UPSTREAM_TIMEOUT_MS);
|
|
665
|
+
if (!response.ok) {
|
|
666
|
+
const detail = await response.text().catch(() => "");
|
|
667
|
+
if (isStabilityRouteMiss(response.status, detail)) throw new StabilityRouteMissError();
|
|
668
|
+
throw new AudioGenError(`Stable Audio API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
|
|
669
|
+
}
|
|
670
|
+
if (response.status === 202) {
|
|
671
|
+
const payload = await response.json().catch(() => ({}));
|
|
672
|
+
if (payload.id === void 0 || payload.id === "") throw new AudioGenError("Stable Audio accepted the job but returned no result id", "audio-empty-result");
|
|
673
|
+
const resultUrl = `${base.replace(/\/v2beta\/audio$/i, "")}/v2beta/audio/results/${encodeURIComponent(payload.id)}`;
|
|
674
|
+
const deadline = Date.now() + UPSTREAM_TIMEOUT_MS;
|
|
675
|
+
while (Date.now() < deadline) {
|
|
676
|
+
if (signal?.aborted === true) throw new AudioGenError("Stable Audio generation was aborted", "audio-aborted");
|
|
677
|
+
const polled = await fetchWithTimeout(resultUrl, {
|
|
678
|
+
method: "GET",
|
|
679
|
+
redirect: "error",
|
|
680
|
+
headers: {
|
|
681
|
+
authorization: `Bearer ${channel.apiKey.trim()}`,
|
|
682
|
+
accept: "application/json"
|
|
683
|
+
},
|
|
684
|
+
signal
|
|
685
|
+
}, 6e4);
|
|
686
|
+
if (polled.ok) return normalizeAudioResponse(polled, {
|
|
687
|
+
apiKey: channel.apiKey,
|
|
688
|
+
fallbackMime: "audio/mpeg"
|
|
689
|
+
});
|
|
690
|
+
if (polled.status === 404 || polled.status === 202) {
|
|
691
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const detail = await polled.text().catch(() => "");
|
|
695
|
+
throw new AudioGenError(`Stable Audio result API error (HTTP ${polled.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
|
|
696
|
+
}
|
|
697
|
+
throw new AudioGenError("Stable Audio generation timed out waiting for the result", "audio-timeout");
|
|
698
|
+
}
|
|
699
|
+
return normalizeAudioResponse(response, {
|
|
700
|
+
apiKey: channel.apiKey,
|
|
701
|
+
fallbackMime: "audio/mpeg"
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Stable Audio 经 OpenAI 兼容网关(如 New API 的 /v1/audio/speech):
|
|
706
|
+
* 模型名映射到 Stable 上游,JSON 体为 {model, input, output_format, duration,
|
|
707
|
+
* seed, steps, cfg_scale} —— 与官方 v2beta 字段一一对应,网关负责转发。
|
|
708
|
+
*/
|
|
709
|
+
async function stabilityGatewayAudio(channel, request, signal) {
|
|
710
|
+
const rawBase = endpointBase(channel.apiUrl);
|
|
711
|
+
const model = (request.upstream ?? request.model) || "stable-audio-2.5";
|
|
712
|
+
const isV3 = /^stable-audio-3/i.test(model);
|
|
713
|
+
const isV2 = /^stable-audio-2(\.[05])?$/i.test(model) || /^stable-audio-2-/i.test(model);
|
|
714
|
+
const endpoint = /\/audio\/speech(\?|$)/i.test(rawBase) ? rawBase : `${rawBase}/audio/speech`;
|
|
715
|
+
const format = request.format === "wav" ? "wav" : "mp3";
|
|
510
716
|
const body = {
|
|
511
|
-
model
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
...request.
|
|
717
|
+
model,
|
|
718
|
+
input: request.prompt,
|
|
719
|
+
output_format: format,
|
|
720
|
+
...request.duration !== void 0 && Number.isFinite(request.duration) ? { duration: Math.min(isV3 ? 380 : 190, Math.max(1, request.duration)) } : {},
|
|
721
|
+
...request.seed !== void 0 && Number.isFinite(request.seed) ? { seed: Math.floor(Math.min(4294967294, Math.max(0, request.seed))) } : {},
|
|
722
|
+
...request.steps !== void 0 && Number.isInteger(request.steps) ? { steps: Math.min(isV2 && !/2\.5/i.test(model) ? 100 : 8, Math.max(isV2 && !/2\.5/i.test(model) ? 30 : 4, request.steps)) } : {},
|
|
723
|
+
...request.cfgScale !== void 0 && Number.isFinite(request.cfgScale) ? { cfg_scale: Math.min(25, Math.max(1, request.cfgScale)) } : {}
|
|
515
724
|
};
|
|
516
|
-
|
|
725
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
517
726
|
method: "POST",
|
|
518
|
-
redirect: "
|
|
727
|
+
redirect: "follow",
|
|
519
728
|
headers: {
|
|
520
729
|
authorization: `Bearer ${channel.apiKey.trim()}`,
|
|
521
|
-
|
|
522
|
-
|
|
730
|
+
accept: "audio/*",
|
|
731
|
+
"content-type": "application/json"
|
|
523
732
|
},
|
|
524
733
|
body: JSON.stringify(body),
|
|
525
734
|
signal
|
|
526
|
-
}, UPSTREAM_TIMEOUT_MS)
|
|
735
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
736
|
+
if (!response.ok) {
|
|
737
|
+
const detail = await response.text().catch(() => "");
|
|
738
|
+
if (isStabilityRouteMiss(response.status, detail)) throw new StabilityRouteMissError();
|
|
739
|
+
throw new AudioGenError(`Stable Audio gateway API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
|
|
740
|
+
}
|
|
741
|
+
return normalizeAudioResponse(response, {
|
|
527
742
|
apiKey: channel.apiKey,
|
|
528
743
|
fallbackMime: "audio/mpeg"
|
|
529
744
|
});
|
|
530
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* 稳定性入口:优先官方 v2beta(api.stability.ai / v2beta 形态),
|
|
748
|
+
* 网关形态(apiUrl 以 /v1 结尾或已含 /audio/speech)优先 OpenAI 兼容;
|
|
749
|
+
* 一方返回 404 Invalid URL(未路由)时自动换另一方重试。
|
|
750
|
+
*/
|
|
751
|
+
async function stabilityAudio(channel, request, signal) {
|
|
752
|
+
const styles = stabilityGatewayStyle(channel) ? ["gateway", "native"] : ["native", "gateway"];
|
|
753
|
+
let lastError;
|
|
754
|
+
for (const style of styles) try {
|
|
755
|
+
if (style === "gateway") return await stabilityGatewayAudio(channel, request, signal);
|
|
756
|
+
return await stabilityNativeAudio(channel, request, signal);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
if (!(error instanceof StabilityRouteMissError)) throw error;
|
|
759
|
+
lastError = error;
|
|
760
|
+
}
|
|
761
|
+
throw lastError ?? new AudioGenError("Stable Audio 渠道未配置或不可达", "audio-api-error");
|
|
762
|
+
}
|
|
531
763
|
async function genericAudio(channel, request, signal) {
|
|
532
764
|
const base = endpointBase(channel.apiUrl);
|
|
533
765
|
if (request.mode === "tts" && !/\/generate(\?|$)/i.test(base)) return openAITTS(channel, request, signal);
|
|
@@ -563,10 +795,10 @@ async function generateAudio(channel, request, signal) {
|
|
|
563
795
|
if (channel.apiUrl.trim() === "") throw new AudioGenError("channel API URL is not configured", "audio-no-endpoint");
|
|
564
796
|
if (channel.apiKey.trim() === "") throw new AudioGenError("channel API key is not configured", "audio-no-key");
|
|
565
797
|
if (request.prompt.trim() === "") throw new AudioGenError("audio prompt/text is required", "audio-empty-prompt");
|
|
566
|
-
if (request.mode === "voice_design" && !isMiniMax$1(channel)) throw new AudioGenError("音色设计当前仅支持 MiniMax
|
|
798
|
+
if (request.mode === "voice_design" && !isMiniMax$1(channel) && !isElevenLabs$1(channel)) throw new AudioGenError("音色设计当前仅支持 MiniMax(/v1/voice_design)与 ElevenLabs(/v1/text-to-voice/design)渠道", "voice-design-unsupported");
|
|
567
799
|
if (isElevenLabs$1(channel)) return elevenLabs(channel, request, signal);
|
|
568
800
|
if (isMiniMax$1(channel)) return minimax(channel, request, signal);
|
|
569
|
-
if (isStability$1(channel)) return stabilityAudio(channel, request, signal);
|
|
801
|
+
if (isStability$1(channel) || /^stable-audio-/i.test(((request.upstream ?? request.model) || "").trim())) return stabilityAudio(channel, request, signal);
|
|
570
802
|
if (isOpenAICompatible(channel, request.mode)) return openAITTS(channel, request, signal);
|
|
571
803
|
return genericAudio(channel, request, signal);
|
|
572
804
|
}
|
|
@@ -642,7 +874,7 @@ const AUDIO_PRESETS = [
|
|
|
642
874
|
name: "ElevenLabs",
|
|
643
875
|
apiUrl: "https://api.elevenlabs.io/v1",
|
|
644
876
|
site: "https://elevenlabsai.cn",
|
|
645
|
-
hint: "ElevenLabs 语音合成(TTS
|
|
877
|
+
hint: "ElevenLabs 语音合成(TTS)与音乐生成(POST /v1/music,music_v2);可点「获取可用模型」拉取音色与模型",
|
|
646
878
|
models: [
|
|
647
879
|
{
|
|
648
880
|
alias: "Rachel",
|
|
@@ -678,6 +910,21 @@ const AUDIO_PRESETS = [
|
|
|
678
910
|
alias: "eleven_flash_v2_5",
|
|
679
911
|
id: "eleven_flash_v2_5",
|
|
680
912
|
category: "tts"
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
alias: "music_v2",
|
|
916
|
+
id: "music_v2",
|
|
917
|
+
category: "music"
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
alias: "music_v1",
|
|
921
|
+
id: "music_v1",
|
|
922
|
+
category: "music"
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
alias: "eleven_text_to_sound_v2",
|
|
926
|
+
id: "eleven_text_to_sound_v2",
|
|
927
|
+
category: "sfx"
|
|
681
928
|
}
|
|
682
929
|
]
|
|
683
930
|
},
|
|
@@ -686,16 +933,24 @@ const AUDIO_PRESETS = [
|
|
|
686
933
|
name: "Stability AI(stable-audio)",
|
|
687
934
|
apiUrl: "https://api.stability.ai/v2beta/audio",
|
|
688
935
|
site: "https://stability.ai/stable-audio",
|
|
689
|
-
hint: "Stability AI 音乐 /
|
|
690
|
-
models: [
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
936
|
+
hint: "Stability AI 文本到音频(TTS 描述 / 音乐 / 音效,stable-audio 系列;stable-audio-3 为异步任务)",
|
|
937
|
+
models: [
|
|
938
|
+
{
|
|
939
|
+
alias: "stable-audio-3",
|
|
940
|
+
id: "stable-audio-3",
|
|
941
|
+
category: "music"
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
alias: "stable-audio-2.5",
|
|
945
|
+
id: "stable-audio-2.5",
|
|
946
|
+
category: "music"
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
alias: "stable-audio-2",
|
|
950
|
+
id: "stable-audio-2",
|
|
951
|
+
category: "music"
|
|
952
|
+
}
|
|
953
|
+
]
|
|
699
954
|
}
|
|
700
955
|
];
|
|
701
956
|
/** Look up one built-in provider by id. */
|
|
@@ -893,12 +1148,16 @@ function dedupe(models) {
|
|
|
893
1148
|
/**
|
|
894
1149
|
* Host-side persistence for generated audio and generation history.
|
|
895
1150
|
* Files live under ~/.dsh/dsh-audiogen/audio/; history is one JSON document.
|
|
1151
|
+
* The resource library lives under ~/.dsh/dsh-audiogen/library/ with one
|
|
1152
|
+
* index JSON plus files organized by type (voice/music/sfx/tts) and category.
|
|
896
1153
|
*/
|
|
897
1154
|
function dshHome() {
|
|
898
1155
|
return process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh");
|
|
899
1156
|
}
|
|
900
1157
|
const AUDIO_DATA_DIR = path.join(dshHome(), "dsh-audiogen", "audio");
|
|
901
1158
|
const HISTORY_FILE = path.join(dshHome(), "dsh-audiogen", "history.json");
|
|
1159
|
+
const LIBRARY_DATA_DIR = path.join(dshHome(), "dsh-audiogen", "library");
|
|
1160
|
+
const LIBRARY_INDEX_FILE = path.join(LIBRARY_DATA_DIR, "index.json");
|
|
902
1161
|
async function ensureDir() {
|
|
903
1162
|
await mkdir(AUDIO_DATA_DIR, { recursive: true });
|
|
904
1163
|
}
|
|
@@ -981,7 +1240,8 @@ async function appendHistory(entry) {
|
|
|
981
1240
|
...audio.voiceId === void 0 ? {} : { voiceId: audio.voiceId }
|
|
982
1241
|
})),
|
|
983
1242
|
...entry.channelId === void 0 ? {} : { channelId: entry.channelId },
|
|
984
|
-
...entry.channel === void 0 ? {} : { channel: entry.channel }
|
|
1243
|
+
...entry.channel === void 0 ? {} : { channel: entry.channel },
|
|
1244
|
+
...entry.params === void 0 ? {} : { params: entry.params }
|
|
985
1245
|
}, ...list].slice(0, 50);
|
|
986
1246
|
await writeHistory(next);
|
|
987
1247
|
return next;
|
|
@@ -998,6 +1258,205 @@ async function clearHistory() {
|
|
|
998
1258
|
await writeHistory([]);
|
|
999
1259
|
return [];
|
|
1000
1260
|
}
|
|
1261
|
+
/** Library type dir names (whitelisted on the audio route too). */
|
|
1262
|
+
const LIBRARY_TYPE_DIRS = {
|
|
1263
|
+
voice: "voice",
|
|
1264
|
+
music: "music",
|
|
1265
|
+
sfx: "sfx",
|
|
1266
|
+
tts: "tts"
|
|
1267
|
+
};
|
|
1268
|
+
/** Sanitize one path segment (cid or voice key). Falls back to 'default'. */
|
|
1269
|
+
function sanitizeSegment(value) {
|
|
1270
|
+
const cleaned = value.replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]+/g, "_").replace(/^[._-]+|[._-]+$/g, "").slice(0, 60);
|
|
1271
|
+
return cleaned === "" ? "default" : cleaned;
|
|
1272
|
+
}
|
|
1273
|
+
/** Infer the category for a save when the client did not provide one. */
|
|
1274
|
+
function defaultLibraryCategory(type, meta) {
|
|
1275
|
+
if (type === "voice") {
|
|
1276
|
+
const probe = `${meta.voiceId ?? ""} ${meta.voice ?? ""}`.toLowerCase();
|
|
1277
|
+
if (/female|女/.test(probe)) return "female";
|
|
1278
|
+
if (/male|男/.test(probe)) return "male";
|
|
1279
|
+
return "custom";
|
|
1280
|
+
}
|
|
1281
|
+
if (type === "tts") return sanitizeSegment(meta.voice ?? meta.voiceId ?? "default");
|
|
1282
|
+
}
|
|
1283
|
+
/** Default resource name from the prompt. */
|
|
1284
|
+
function defaultLibraryName(prompt) {
|
|
1285
|
+
const flat = prompt.replace(/\s+/g, " ").trim();
|
|
1286
|
+
return flat === "" ? "未命名音频" : flat.length > 40 ? `${flat.slice(0, 40)}…` : flat;
|
|
1287
|
+
}
|
|
1288
|
+
async function readLibraryIndex() {
|
|
1289
|
+
try {
|
|
1290
|
+
const text = await readFile(LIBRARY_INDEX_FILE, "utf8");
|
|
1291
|
+
const parsed = JSON.parse(text);
|
|
1292
|
+
if (!Array.isArray(parsed)) return [];
|
|
1293
|
+
return parsed.filter(isLibraryEntry);
|
|
1294
|
+
} catch {
|
|
1295
|
+
return [];
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
function isLibraryEntry(value) {
|
|
1299
|
+
if (value === null || typeof value !== "object") return false;
|
|
1300
|
+
const raw = value;
|
|
1301
|
+
return typeof raw.id === "string" && typeof raw.name === "string" && (raw.type === "voice" || raw.type === "music" || raw.type === "sfx" || raw.type === "tts") && Array.isArray(raw.files) && typeof raw.createdAt === "number" && typeof raw.provenance === "object";
|
|
1302
|
+
}
|
|
1303
|
+
async function writeLibraryIndex(entries) {
|
|
1304
|
+
await mkdir(LIBRARY_DATA_DIR, { recursive: true });
|
|
1305
|
+
await writeFile(LIBRARY_INDEX_FILE, JSON.stringify(entries, null, 2));
|
|
1306
|
+
}
|
|
1307
|
+
/** Same-origin URL for a library-relative file path. */
|
|
1308
|
+
function libraryUrlOf(rel) {
|
|
1309
|
+
return `${LIBRARY_API.audio}/${rel.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`;
|
|
1310
|
+
}
|
|
1311
|
+
/** Merge-library-entry: copy one audio/ file into library/<type>/<category>/. */
|
|
1312
|
+
async function copyIntoLibrary(input, typeDir, category) {
|
|
1313
|
+
const stored = await readAudioFile(input.file);
|
|
1314
|
+
if (stored === void 0) throw new Error(`音频文件不存在:${input.file}(请重新生成后再入库)`);
|
|
1315
|
+
const ext = path.extname(input.file).replace(".", "") || (stored.mime.split("/")[1]?.replace("mpeg", "mp3") ?? "bin");
|
|
1316
|
+
const rel = `${typeDir}/${category}/${input.id}.${ext}`;
|
|
1317
|
+
const target = path.join(LIBRARY_DATA_DIR, ...rel.split("/"));
|
|
1318
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1319
|
+
await writeFile(target, stored.data);
|
|
1320
|
+
return {
|
|
1321
|
+
url: libraryUrlOf(rel),
|
|
1322
|
+
rel,
|
|
1323
|
+
mime: stored.mime,
|
|
1324
|
+
bytes: stored.bytes,
|
|
1325
|
+
...input.duration === void 0 ? {} : { duration: input.duration },
|
|
1326
|
+
...input.voiceId === void 0 ? {} : { voiceId: input.voiceId }
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Save one curated library entry: copies the referenced audio files into
|
|
1331
|
+
* library/<type>/<category>/ (audio/ files stay untouched) and appends the
|
|
1332
|
+
* entry to the index.
|
|
1333
|
+
*/
|
|
1334
|
+
async function saveToLibrary(input) {
|
|
1335
|
+
if (input.audioFiles.length === 0) throw new Error("没有可入库的音频文件");
|
|
1336
|
+
const typeDir = LIBRARY_TYPE_DIRS[input.type];
|
|
1337
|
+
const category = input.category !== void 0 && input.category.trim() !== "" ? sanitizeSegment(input.category.trim()) : defaultLibraryCategory(input.type, {
|
|
1338
|
+
voice: input.provenance.voice,
|
|
1339
|
+
voiceId: input.provenance.voiceId ?? input.audioFiles.find((file) => file.voiceId !== void 0)?.voiceId
|
|
1340
|
+
}) ?? "default";
|
|
1341
|
+
const files = await Promise.all(input.audioFiles.map((file) => copyIntoLibrary(file, typeDir, category)));
|
|
1342
|
+
const rawName = (input.name ?? "").trim();
|
|
1343
|
+
const entry = {
|
|
1344
|
+
id: randomUUID(),
|
|
1345
|
+
createdAt: Date.now(),
|
|
1346
|
+
type: input.type,
|
|
1347
|
+
category: category === "default" && input.type !== "voice" && input.type !== "tts" ? void 0 : category,
|
|
1348
|
+
name: rawName === "" ? defaultLibraryName(input.provenance.prompt) : rawName,
|
|
1349
|
+
tags: Array.isArray(input.tags) ? [...new Set(input.tags.map((tag) => tag.trim()).filter((tag) => tag !== ""))].slice(0, 20) : [],
|
|
1350
|
+
...input.note !== void 0 && input.note.trim() !== "" ? { note: input.note.trim() } : {},
|
|
1351
|
+
files,
|
|
1352
|
+
provenance: input.provenance
|
|
1353
|
+
};
|
|
1354
|
+
const entries = await readLibraryIndex();
|
|
1355
|
+
entries.unshift(entry);
|
|
1356
|
+
await writeLibraryIndex(entries);
|
|
1357
|
+
return entry;
|
|
1358
|
+
}
|
|
1359
|
+
/** Read library entries (newest first). */
|
|
1360
|
+
async function listLibrary() {
|
|
1361
|
+
return [...await readLibraryIndex()].sort((a, b) => b.createdAt - a.createdAt);
|
|
1362
|
+
}
|
|
1363
|
+
/** Move one library-relative file to a new rel path (same volume rename, else copy). */
|
|
1364
|
+
async function moveLibraryFile(fromRel, toRel) {
|
|
1365
|
+
const from = path.join(LIBRARY_DATA_DIR, ...fromRel.split("/"));
|
|
1366
|
+
const to = path.join(LIBRARY_DATA_DIR, ...toRel.split("/"));
|
|
1367
|
+
await mkdir(path.dirname(to), { recursive: true });
|
|
1368
|
+
try {
|
|
1369
|
+
await rename(from, to);
|
|
1370
|
+
} catch {
|
|
1371
|
+
await writeFile(to, await readFile(from));
|
|
1372
|
+
await unlink(from);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/** Patch name/tags/note/type/category; moving type/category relocates files. */
|
|
1376
|
+
async function updateLibraryEntry(id, patch) {
|
|
1377
|
+
const entries = await readLibraryIndex();
|
|
1378
|
+
const index = entries.findIndex((entry) => entry.id === id);
|
|
1379
|
+
if (index < 0) return void 0;
|
|
1380
|
+
const entry = {
|
|
1381
|
+
...entries[index],
|
|
1382
|
+
files: [...entries[index].files]
|
|
1383
|
+
};
|
|
1384
|
+
if (patch.type !== void 0 && LIBRARY_TYPES_VALID.includes(patch.type)) entry.type = patch.type;
|
|
1385
|
+
if (patch.name !== void 0) entry.name = patch.name.trim() === "" ? defaultLibraryName(entry.provenance.prompt) : patch.name.trim();
|
|
1386
|
+
if (patch.tags !== void 0) entry.tags = [...new Set(patch.tags.map((tag) => tag.trim()).filter((tag) => tag !== ""))].slice(0, 20);
|
|
1387
|
+
if (patch.note !== void 0) entry.note = patch.note.trim() === "" ? void 0 : patch.note.trim();
|
|
1388
|
+
if (patch.category !== void 0 && patch.category.trim() !== "") {
|
|
1389
|
+
const next = sanitizeSegment(patch.category.trim());
|
|
1390
|
+
if (entry.type === "voice" || entry.type === "tts") entry.category = next;
|
|
1391
|
+
}
|
|
1392
|
+
const oldCat = entries[index].category ?? "default";
|
|
1393
|
+
const newCat = entry.category ?? "default";
|
|
1394
|
+
if (entries[index].type !== entry.type || oldCat !== newCat) {
|
|
1395
|
+
const moved = [];
|
|
1396
|
+
for (const file of entry.files) {
|
|
1397
|
+
const fileName = file.rel.split("/").pop() ?? "";
|
|
1398
|
+
const fromRel = `${LIBRARY_TYPE_DIRS[entries[index].type]}/${oldCat}/${fileName}`;
|
|
1399
|
+
const toRel = `${LIBRARY_TYPE_DIRS[entry.type]}/${newCat}/${fileName}`;
|
|
1400
|
+
if (fromRel !== toRel) await moveLibraryFile(fromRel, toRel);
|
|
1401
|
+
moved.push({
|
|
1402
|
+
...file,
|
|
1403
|
+
rel: toRel,
|
|
1404
|
+
url: libraryUrlOf(toRel)
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
entry.files = moved;
|
|
1408
|
+
}
|
|
1409
|
+
entries[index] = entry;
|
|
1410
|
+
await writeLibraryIndex(entries);
|
|
1411
|
+
return entry;
|
|
1412
|
+
}
|
|
1413
|
+
/** Remove entries and their audio files; best-effort prune empty dirs. */
|
|
1414
|
+
async function removeLibraryEntries(ids) {
|
|
1415
|
+
const entries = await readLibraryIndex();
|
|
1416
|
+
const doomed = new Set(ids);
|
|
1417
|
+
const kept = entries.filter((entry) => !doomed.has(entry.id));
|
|
1418
|
+
for (const entry of entries) {
|
|
1419
|
+
if (!doomed.has(entry.id)) continue;
|
|
1420
|
+
for (const file of entry.files) try {
|
|
1421
|
+
await unlink(path.join(LIBRARY_DATA_DIR, ...file.rel.split("/")));
|
|
1422
|
+
} catch {}
|
|
1423
|
+
}
|
|
1424
|
+
for (const entry of entries) {
|
|
1425
|
+
if (!doomed.has(entry.id)) continue;
|
|
1426
|
+
try {
|
|
1427
|
+
await rmdir(path.dirname(path.join(LIBRARY_DATA_DIR, ...entry.files[0].rel.split("/"))), { recursive: false });
|
|
1428
|
+
} catch {}
|
|
1429
|
+
}
|
|
1430
|
+
await writeLibraryIndex(kept);
|
|
1431
|
+
return kept;
|
|
1432
|
+
}
|
|
1433
|
+
/** Read one library file by its rel path (whitelisted, traversal-safe). */
|
|
1434
|
+
async function readLibraryFile(rel) {
|
|
1435
|
+
const segments = rel.split("/").filter((segment) => segment !== "");
|
|
1436
|
+
if (segments.length < 2 || segments.length > 3) return void 0;
|
|
1437
|
+
const [typeDir, category, fileName] = segments;
|
|
1438
|
+
if (typeDir === void 0 || !Object.values(LIBRARY_TYPE_DIRS).includes(typeDir)) return void 0;
|
|
1439
|
+
if (category === void 0 || sanitizeSegment(category) !== category || category.length > 60) return void 0;
|
|
1440
|
+
if (fileName === void 0 || !/^[0-9a-f-]{36}\.[a-z0-9]{2,5}$/i.test(fileName)) return void 0;
|
|
1441
|
+
const full = path.join(LIBRARY_DATA_DIR, typeDir, category, fileName);
|
|
1442
|
+
if (!full.startsWith(path.join(LIBRARY_DATA_DIR, typeDir, category) + path.sep)) return void 0;
|
|
1443
|
+
try {
|
|
1444
|
+
const data = await readFile(full);
|
|
1445
|
+
return {
|
|
1446
|
+
data,
|
|
1447
|
+
mime: mimeFromFile(fileName),
|
|
1448
|
+
bytes: data.byteLength
|
|
1449
|
+
};
|
|
1450
|
+
} catch {
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const LIBRARY_TYPES_VALID = [
|
|
1455
|
+
"voice",
|
|
1456
|
+
"music",
|
|
1457
|
+
"sfx",
|
|
1458
|
+
"tts"
|
|
1459
|
+
];
|
|
1001
1460
|
//#endregion
|
|
1002
1461
|
//#region src/routes.ts
|
|
1003
1462
|
const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
|
|
@@ -1078,6 +1537,11 @@ function parseGenerateRequest(body) {
|
|
|
1078
1537
|
...num(body.duration) !== void 0 ? { duration: num(body.duration) } : {},
|
|
1079
1538
|
...typeof body.lyrics === "string" && body.lyrics.trim() !== "" ? { lyrics: body.lyrics.trim() } : {},
|
|
1080
1539
|
...typeof body.isInstrumental === "boolean" ? { isInstrumental: body.isInstrumental } : {},
|
|
1540
|
+
...typeof body.loop === "boolean" ? { loop: body.loop } : {},
|
|
1541
|
+
...num(body.promptInfluence) !== void 0 ? { promptInfluence: num(body.promptInfluence) } : {},
|
|
1542
|
+
...num(body.seed) !== void 0 ? { seed: num(body.seed) } : {},
|
|
1543
|
+
...num(body.steps) !== void 0 ? { steps: num(body.steps) } : {},
|
|
1544
|
+
...num(body.cfgScale) !== void 0 ? { cfgScale: num(body.cfgScale) } : {},
|
|
1081
1545
|
...typeof body.format === "string" && body.format.trim() !== "" ? { format: body.format.trim() } : {},
|
|
1082
1546
|
...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {},
|
|
1083
1547
|
...str(body.emotion) !== void 0 ? { emotion: str(body.emotion) } : {},
|
|
@@ -1094,7 +1558,8 @@ function parseGenerateRequest(body) {
|
|
|
1094
1558
|
...flag(body.aigcWatermark) !== void 0 ? { aigcWatermark: flag(body.aigcWatermark) } : {},
|
|
1095
1559
|
...str(body.languageBoost) !== void 0 ? { languageBoost: str(body.languageBoost) } : {},
|
|
1096
1560
|
...voiceModify !== void 0 ? { voiceModify } : {},
|
|
1097
|
-
...timbreWeights !== void 0 && timbreWeights.length > 0 ? { timbreWeights } : {}
|
|
1561
|
+
...timbreWeights !== void 0 && timbreWeights.length > 0 ? { timbreWeights } : {},
|
|
1562
|
+
...flag(body.saveToLibrary) !== void 0 ? { saveToLibrary: flag(body.saveToLibrary) } : {}
|
|
1098
1563
|
};
|
|
1099
1564
|
}
|
|
1100
1565
|
function toView(descriptor) {
|
|
@@ -1136,22 +1601,24 @@ function resolveChannelRequest(request, view) {
|
|
|
1136
1601
|
const defaults = view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
|
|
1137
1602
|
const target = explicit ?? defaults;
|
|
1138
1603
|
const asked = request.model.trim();
|
|
1604
|
+
if (request.mode === "voice_design") {
|
|
1605
|
+
if (target === void 0) return {
|
|
1606
|
+
ok: false,
|
|
1607
|
+
code: "no-channels",
|
|
1608
|
+
message: "尚未配置任何渠道"
|
|
1609
|
+
};
|
|
1610
|
+
return {
|
|
1611
|
+
ok: true,
|
|
1612
|
+
request: {
|
|
1613
|
+
...request,
|
|
1614
|
+
model: "",
|
|
1615
|
+
upstream: void 0,
|
|
1616
|
+
channelId: target.id,
|
|
1617
|
+
channel: target.name
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1139
1621
|
if (asked === "") {
|
|
1140
|
-
if (request.mode === "voice_design") {
|
|
1141
|
-
if (target === void 0) return {
|
|
1142
|
-
ok: false,
|
|
1143
|
-
code: "no-channels",
|
|
1144
|
-
message: "尚未配置任何渠道"
|
|
1145
|
-
};
|
|
1146
|
-
return {
|
|
1147
|
-
ok: true,
|
|
1148
|
-
request: {
|
|
1149
|
-
...request,
|
|
1150
|
-
channelId: target.id,
|
|
1151
|
-
channel: target.name
|
|
1152
|
-
}
|
|
1153
|
-
};
|
|
1154
|
-
}
|
|
1155
1622
|
const alias = target?.models[0]?.alias ?? "";
|
|
1156
1623
|
if (alias === "") return {
|
|
1157
1624
|
ok: false,
|
|
@@ -1189,6 +1656,60 @@ function resolveChannelRequest(request, view) {
|
|
|
1189
1656
|
}
|
|
1190
1657
|
};
|
|
1191
1658
|
}
|
|
1659
|
+
/** Build the library type from a generation mode (voice_design → voice). */
|
|
1660
|
+
function libraryTypeOf$1(mode) {
|
|
1661
|
+
if (mode === "voice_design") return "voice";
|
|
1662
|
+
return mode;
|
|
1663
|
+
}
|
|
1664
|
+
/** Provenance snapshot straight from a resolved generate request. */
|
|
1665
|
+
function provenanceOf(request, apiUrl) {
|
|
1666
|
+
return {
|
|
1667
|
+
mode: request.mode,
|
|
1668
|
+
prompt: request.prompt,
|
|
1669
|
+
...request.channel === void 0 ? {} : { channel: request.channel },
|
|
1670
|
+
...request.channelId === void 0 ? {} : { channelId: request.channelId },
|
|
1671
|
+
...apiUrl === "" ? {} : { apiUrl },
|
|
1672
|
+
...request.model === void 0 || request.model === "" ? {} : { model: request.model },
|
|
1673
|
+
...request.upstream === void 0 || request.upstream === "" ? {} : { upstream: request.upstream },
|
|
1674
|
+
...request.voice === void 0 ? {} : { voice: request.voice },
|
|
1675
|
+
params: { ...request }
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
const strOf = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
1679
|
+
const strListOf = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()) : void 0;
|
|
1680
|
+
const parseModeOf = (value) => value === "music" ? "music" : value === "sfx" ? "sfx" : value === "voice_design" ? "voice_design" : "tts";
|
|
1681
|
+
const parseLibraryTypeOf = (value) => LIBRARY_TYPES.includes(value) ? value : void 0;
|
|
1682
|
+
/** File name (audio/ id.ext) from a same-origin audio url. */
|
|
1683
|
+
function historyFileIdOf(url) {
|
|
1684
|
+
try {
|
|
1685
|
+
return decodeURIComponent(new URL(url, "http://localhost").pathname.split("/").pop() ?? "");
|
|
1686
|
+
} catch {
|
|
1687
|
+
return "";
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Fill missing provenance fields from host-persisted history (which carries
|
|
1692
|
+
* the resolved request snapshot) and the channel catalog. Client-supplied
|
|
1693
|
+
* values win when present.
|
|
1694
|
+
*/
|
|
1695
|
+
async function mergeLibraryProvenance(given, files, channels) {
|
|
1696
|
+
const wanted = new Set(files.map((file) => file.file));
|
|
1697
|
+
const entry = (await listHistory()).find((candidate) => candidate.audio.some((audio) => wanted.has(historyFileIdOf(audio.url))));
|
|
1698
|
+
const params = entry?.params !== void 0 && typeof entry.params === "object" ? entry.params : void 0;
|
|
1699
|
+
const channel = channels.find((candidate) => candidate.id === (entry?.channelId ?? ""));
|
|
1700
|
+
return {
|
|
1701
|
+
mode: entry?.mode ?? given.mode,
|
|
1702
|
+
prompt: given.prompt !== "" ? given.prompt : entry?.prompt ?? "",
|
|
1703
|
+
...given.channel !== void 0 || entry?.channel !== void 0 ? { channel: given.channel ?? entry?.channel } : {},
|
|
1704
|
+
...given.channelId !== void 0 || entry?.channelId !== void 0 ? { channelId: given.channelId ?? entry?.channelId } : {},
|
|
1705
|
+
...(given.apiUrl ?? channel?.apiUrl ?? "") === "" ? {} : { apiUrl: given.apiUrl ?? channel?.apiUrl },
|
|
1706
|
+
...given.model !== void 0 || entry?.model !== void 0 ? { model: given.model ?? entry?.model } : {},
|
|
1707
|
+
...(given.upstream ?? (typeof params?.upstream === "string" ? params.upstream : void 0)) !== void 0 ? { upstream: given.upstream ?? (typeof params?.upstream === "string" ? params.upstream : void 0) } : {},
|
|
1708
|
+
...given.voice !== void 0 || entry?.voice !== void 0 ? { voice: given.voice ?? entry?.voice } : {},
|
|
1709
|
+
...given.voiceId !== void 0 || entry?.voiceId !== void 0 ? { voiceId: given.voiceId ?? entry?.voiceId } : {},
|
|
1710
|
+
...given.params !== void 0 || params !== void 0 ? { params: given.params ?? params } : {}
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1192
1713
|
/** Build every /api/dsh-audiogen route. */
|
|
1193
1714
|
function makeRoutes(deps) {
|
|
1194
1715
|
const guard = (req, res, method) => {
|
|
@@ -1349,6 +1870,7 @@ function makeRoutes(deps) {
|
|
|
1349
1870
|
const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`);
|
|
1350
1871
|
generated.push({
|
|
1351
1872
|
id: saved.id,
|
|
1873
|
+
file: saved.file,
|
|
1352
1874
|
b64: Buffer.from(output.data).toString("base64"),
|
|
1353
1875
|
mime: saved.mime,
|
|
1354
1876
|
bytes: saved.bytes,
|
|
@@ -1356,6 +1878,7 @@ function makeRoutes(deps) {
|
|
|
1356
1878
|
...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
|
|
1357
1879
|
});
|
|
1358
1880
|
}
|
|
1881
|
+
const paramsSnapshot = { ...request };
|
|
1359
1882
|
let history;
|
|
1360
1883
|
try {
|
|
1361
1884
|
history = await appendHistory({
|
|
@@ -1370,7 +1893,8 @@ function makeRoutes(deps) {
|
|
|
1370
1893
|
...request.format === void 0 ? {} : { format: request.format },
|
|
1371
1894
|
audio: generated,
|
|
1372
1895
|
...request.channelId === void 0 ? {} : { channelId: request.channelId },
|
|
1373
|
-
...request.channel === void 0 ? {} : { channel: request.channel }
|
|
1896
|
+
...request.channel === void 0 ? {} : { channel: request.channel },
|
|
1897
|
+
params: paramsSnapshot
|
|
1374
1898
|
});
|
|
1375
1899
|
} catch (error) {
|
|
1376
1900
|
writeJson(res, 200, {
|
|
@@ -1380,10 +1904,30 @@ function makeRoutes(deps) {
|
|
|
1380
1904
|
});
|
|
1381
1905
|
return;
|
|
1382
1906
|
}
|
|
1907
|
+
const wantSave = request.saveToLibrary === true || deps.autoSave() && request.saveToLibrary !== false;
|
|
1908
|
+
let resources;
|
|
1909
|
+
if (wantSave) try {
|
|
1910
|
+
const entry = await saveToLibrary({
|
|
1911
|
+
audioFiles: generated.map((audio) => ({
|
|
1912
|
+
id: audio.id,
|
|
1913
|
+
file: audio.file,
|
|
1914
|
+
mime: audio.mime,
|
|
1915
|
+
...audio.voiceId === void 0 ? {} : { voiceId: audio.voiceId }
|
|
1916
|
+
})),
|
|
1917
|
+
type: libraryTypeOf$1(request.mode),
|
|
1918
|
+
provenance: provenanceOf(request, channel.apiUrl)
|
|
1919
|
+
});
|
|
1920
|
+
resources = [{
|
|
1921
|
+
id: entry.id,
|
|
1922
|
+
name: entry.name,
|
|
1923
|
+
type: entry.type
|
|
1924
|
+
}];
|
|
1925
|
+
} catch {}
|
|
1383
1926
|
writeJson(res, 200, {
|
|
1384
1927
|
ok: true,
|
|
1385
1928
|
outputs: generated,
|
|
1386
|
-
history
|
|
1929
|
+
history,
|
|
1930
|
+
...resources === void 0 ? {} : { resources }
|
|
1387
1931
|
});
|
|
1388
1932
|
} catch (error) {
|
|
1389
1933
|
writeJson(res, 200, {
|
|
@@ -1424,6 +1968,185 @@ function makeRoutes(deps) {
|
|
|
1424
1968
|
res.end(stored.data);
|
|
1425
1969
|
}
|
|
1426
1970
|
},
|
|
1971
|
+
{
|
|
1972
|
+
kind: "exact",
|
|
1973
|
+
path: LIBRARY_API.list,
|
|
1974
|
+
handler: async (req, res) => {
|
|
1975
|
+
if (!guard(req, res, "POST")) return;
|
|
1976
|
+
writeJson(res, 200, {
|
|
1977
|
+
ok: true,
|
|
1978
|
+
entries: await listLibrary()
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
},
|
|
1982
|
+
{
|
|
1983
|
+
kind: "exact",
|
|
1984
|
+
path: LIBRARY_API.save,
|
|
1985
|
+
handler: async (req, res) => {
|
|
1986
|
+
if (!guard(req, res, "POST")) return;
|
|
1987
|
+
const body = await readJsonBody(req);
|
|
1988
|
+
const audioFiles = Array.isArray(body?.audioFiles) ? body.audioFiles.filter((item) => typeof item === "object" && item !== null).map((item) => ({
|
|
1989
|
+
id: strOf(item.id) ?? "",
|
|
1990
|
+
file: strOf(item.file) ?? "",
|
|
1991
|
+
mime: strOf(item.mime) ?? "audio/mpeg",
|
|
1992
|
+
...strOf(item.voiceId) !== void 0 ? { voiceId: strOf(item.voiceId) } : {},
|
|
1993
|
+
...typeof item.duration === "number" && Number.isFinite(item.duration) ? { duration: item.duration } : {}
|
|
1994
|
+
})).filter((item) => item.id !== "" && item.file !== "") : [];
|
|
1995
|
+
if (audioFiles.length === 0) {
|
|
1996
|
+
writeJson(res, 200, {
|
|
1997
|
+
ok: false,
|
|
1998
|
+
code: "bad-request",
|
|
1999
|
+
message: "没有可入库的音频文件"
|
|
2000
|
+
});
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
const type = parseLibraryTypeOf(body?.type);
|
|
2004
|
+
if (type === void 0) {
|
|
2005
|
+
writeJson(res, 200, {
|
|
2006
|
+
ok: false,
|
|
2007
|
+
code: "bad-request",
|
|
2008
|
+
message: "资源类型无效(voice/music/sfx/tts)"
|
|
2009
|
+
});
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
const rawProvenance = typeof body?.provenance === "object" && body.provenance !== null ? body.provenance : {};
|
|
2013
|
+
const provenance = await mergeLibraryProvenance({
|
|
2014
|
+
mode: parseModeOf(rawProvenance.mode),
|
|
2015
|
+
prompt: typeof rawProvenance.prompt === "string" ? rawProvenance.prompt.trim() : "",
|
|
2016
|
+
...strOf(rawProvenance.channel) !== void 0 ? { channel: strOf(rawProvenance.channel) } : {},
|
|
2017
|
+
...strOf(rawProvenance.channelId) !== void 0 ? { channelId: strOf(rawProvenance.channelId) } : {},
|
|
2018
|
+
...strOf(rawProvenance.apiUrl) !== void 0 ? { apiUrl: strOf(rawProvenance.apiUrl) } : {},
|
|
2019
|
+
...strOf(rawProvenance.model) !== void 0 ? { model: strOf(rawProvenance.model) } : {},
|
|
2020
|
+
...strOf(rawProvenance.upstream) !== void 0 ? { upstream: strOf(rawProvenance.upstream) } : {},
|
|
2021
|
+
...strOf(rawProvenance.voice) !== void 0 ? { voice: strOf(rawProvenance.voice) } : {},
|
|
2022
|
+
...strOf(rawProvenance.voiceId) !== void 0 ? { voiceId: strOf(rawProvenance.voiceId) } : {},
|
|
2023
|
+
...typeof rawProvenance.params === "object" && rawProvenance.params !== null ? { params: rawProvenance.params } : {}
|
|
2024
|
+
}, audioFiles, deps.resolveChannels().channels);
|
|
2025
|
+
try {
|
|
2026
|
+
writeJson(res, 200, {
|
|
2027
|
+
ok: true,
|
|
2028
|
+
entry: await saveToLibrary({
|
|
2029
|
+
audioFiles,
|
|
2030
|
+
type,
|
|
2031
|
+
...strOf(body?.category) !== void 0 ? { category: strOf(body?.category) } : {},
|
|
2032
|
+
...strOf(body?.name) !== void 0 ? { name: strOf(body?.name) } : {},
|
|
2033
|
+
...strListOf(body?.tags) !== void 0 ? { tags: strListOf(body?.tags) } : {},
|
|
2034
|
+
...strOf(body?.note) !== void 0 ? { note: strOf(body?.note) } : {},
|
|
2035
|
+
provenance
|
|
2036
|
+
})
|
|
2037
|
+
});
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
writeJson(res, 200, {
|
|
2040
|
+
ok: false,
|
|
2041
|
+
code: "library-save-failed",
|
|
2042
|
+
message: messageOf(error)
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
},
|
|
2047
|
+
{
|
|
2048
|
+
kind: "exact",
|
|
2049
|
+
path: LIBRARY_API.update,
|
|
2050
|
+
handler: async (req, res) => {
|
|
2051
|
+
if (!guard(req, res, "POST")) return;
|
|
2052
|
+
const body = await readJsonBody(req);
|
|
2053
|
+
const id = strOf(body?.id);
|
|
2054
|
+
if (id === void 0) {
|
|
2055
|
+
writeJson(res, 200, {
|
|
2056
|
+
ok: false,
|
|
2057
|
+
code: "bad-request",
|
|
2058
|
+
message: "缺少资源 id"
|
|
2059
|
+
});
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
try {
|
|
2063
|
+
const entry = await updateLibraryEntry(id, {
|
|
2064
|
+
...strOf(body?.name) !== void 0 ? { name: strOf(body?.name) } : {},
|
|
2065
|
+
...strListOf(body?.tags) !== void 0 ? { tags: strListOf(body?.tags) } : {},
|
|
2066
|
+
...typeof body?.note === "string" ? { note: body.note } : {},
|
|
2067
|
+
...strOf(body?.category) !== void 0 ? { category: strOf(body?.category) } : {},
|
|
2068
|
+
...parseLibraryTypeOf(body?.type) !== void 0 ? { type: parseLibraryTypeOf(body?.type) } : {}
|
|
2069
|
+
});
|
|
2070
|
+
if (entry === void 0) {
|
|
2071
|
+
writeJson(res, 200, {
|
|
2072
|
+
ok: false,
|
|
2073
|
+
code: "not-found",
|
|
2074
|
+
message: "资源不存在"
|
|
2075
|
+
});
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
writeJson(res, 200, {
|
|
2079
|
+
ok: true,
|
|
2080
|
+
entry
|
|
2081
|
+
});
|
|
2082
|
+
} catch (error) {
|
|
2083
|
+
writeJson(res, 200, {
|
|
2084
|
+
ok: false,
|
|
2085
|
+
code: "library-update-failed",
|
|
2086
|
+
message: messageOf(error)
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
},
|
|
2091
|
+
{
|
|
2092
|
+
kind: "exact",
|
|
2093
|
+
path: LIBRARY_API.remove,
|
|
2094
|
+
handler: async (req, res) => {
|
|
2095
|
+
if (!guard(req, res, "POST")) return;
|
|
2096
|
+
const body = await readJsonBody(req);
|
|
2097
|
+
const ids = strListOf(body?.ids) ?? [];
|
|
2098
|
+
if (ids.length === 0) {
|
|
2099
|
+
writeJson(res, 200, {
|
|
2100
|
+
ok: false,
|
|
2101
|
+
code: "bad-request",
|
|
2102
|
+
message: "缺少资源 id"
|
|
2103
|
+
});
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
try {
|
|
2107
|
+
writeJson(res, 200, {
|
|
2108
|
+
ok: true,
|
|
2109
|
+
entries: await removeLibraryEntries(ids)
|
|
2110
|
+
});
|
|
2111
|
+
} catch (error) {
|
|
2112
|
+
writeJson(res, 200, {
|
|
2113
|
+
ok: false,
|
|
2114
|
+
code: "library-remove-failed",
|
|
2115
|
+
message: messageOf(error)
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
},
|
|
2120
|
+
{
|
|
2121
|
+
kind: "prefix",
|
|
2122
|
+
path: LIBRARY_API.audio,
|
|
2123
|
+
handler: async (req, res) => {
|
|
2124
|
+
if (!isLoopbackRequest(req)) {
|
|
2125
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (req.method !== "GET") {
|
|
2129
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
const rel = audioFileFrom(req.url, LIBRARY_API.audio);
|
|
2133
|
+
if (rel === void 0) {
|
|
2134
|
+
writeJson(res, 400, { error: "invalid library audio" });
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
const stored = await readLibraryFile(rel);
|
|
2138
|
+
if (stored === void 0) {
|
|
2139
|
+
writeJson(res, 404, { error: "library audio not found" });
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
res.writeHead(200, {
|
|
2143
|
+
"content-type": stored.mime,
|
|
2144
|
+
"content-length": stored.bytes,
|
|
2145
|
+
"cache-control": "private, max-age=3600"
|
|
2146
|
+
});
|
|
2147
|
+
res.end(stored.data);
|
|
2148
|
+
}
|
|
2149
|
+
},
|
|
1427
2150
|
{
|
|
1428
2151
|
kind: "exact",
|
|
1429
2152
|
path: HISTORY_API.list,
|
|
@@ -1545,6 +2268,10 @@ const resultSchema = {
|
|
|
1545
2268
|
}
|
|
1546
2269
|
}
|
|
1547
2270
|
},
|
|
2271
|
+
resources: {
|
|
2272
|
+
type: "array",
|
|
2273
|
+
items: { type: "string" }
|
|
2274
|
+
},
|
|
1548
2275
|
error: { type: "string" }
|
|
1549
2276
|
}
|
|
1550
2277
|
};
|
|
@@ -1575,11 +2302,17 @@ function ensureConfigured(config) {
|
|
|
1575
2302
|
if (!config.allowAgentAudioGeneration) throw new AudioGenError("Agent audio generation is disabled in Settings > Plugins > AI Audio.", "agent-generation-disabled");
|
|
1576
2303
|
if (!config.channels.some((channel) => channel.apiUrl.trim() !== "" && channel.apiKey.trim() !== "")) throw new AudioGenError("Audio API credentials are not configured. Open Settings > Plugins > AI Audio, add a channel and fill its API URL and API key.", "audio-api-not-configured");
|
|
1577
2304
|
}
|
|
2305
|
+
/** Library type from the generation mode, with an explicit override. */
|
|
2306
|
+
function libraryTypeOf(mode, override) {
|
|
2307
|
+
if (override === "voice" || override === "music" || override === "sfx" || override === "tts") return override;
|
|
2308
|
+
if (mode === "voice_design") return "voice";
|
|
2309
|
+
return mode;
|
|
2310
|
+
}
|
|
1578
2311
|
/** Register the Agent audio tool. */
|
|
1579
2312
|
function registerAgentAudioTools(ctx, resolve) {
|
|
1580
|
-
|
|
2313
|
+
const disposer = ctx.tools.register(defineTool({
|
|
1581
2314
|
name: "generate_audio",
|
|
1582
|
-
description: "Generate audio with the configured audio provider. Supports text-to-speech, music generation, sound effects and MiniMax voice
|
|
2315
|
+
description: "Generate audio with the configured audio provider. Supports text-to-speech, music generation, sound effects and voice design (MiniMax /v1/voice_design, ElevenLabs /v1/text-to-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.",
|
|
1583
2316
|
parameters: {
|
|
1584
2317
|
prompt: {
|
|
1585
2318
|
type: "string",
|
|
@@ -1624,6 +2357,26 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1624
2357
|
type: "boolean",
|
|
1625
2358
|
description: "Generate purely instrumental music without vocals/lyrics (MiniMax is_instrumental). When true, lyrics may be omitted."
|
|
1626
2359
|
},
|
|
2360
|
+
loop: {
|
|
2361
|
+
type: "boolean",
|
|
2362
|
+
description: "Create a seamlessly looping sound effect (ElevenLabs sound generation loop, only for eleven_text_to_sound_v2)."
|
|
2363
|
+
},
|
|
2364
|
+
prompt_influence: {
|
|
2365
|
+
type: "number",
|
|
2366
|
+
description: "Sound effect prompt influence 0-1 (ElevenLabs prompt_influence, default 0.3): higher follows the prompt more closely, lower is more variable."
|
|
2367
|
+
},
|
|
2368
|
+
seed: {
|
|
2369
|
+
type: "integer",
|
|
2370
|
+
description: "Stable Audio random seed 0-4294967294 (default 0 = random); same seed yields reproducible audio."
|
|
2371
|
+
},
|
|
2372
|
+
steps: {
|
|
2373
|
+
type: "integer",
|
|
2374
|
+
description: "Stable Audio sampling steps, model-dependent: stable-audio-2 30-100, stable-audio-2.5/3 4-8 (out-of-range auto-clamped)."
|
|
2375
|
+
},
|
|
2376
|
+
cfg_scale: {
|
|
2377
|
+
type: "number",
|
|
2378
|
+
description: "Stable Audio prompt adherence 1-25 (stable-audio-2 default 7, 2.5/3 default 1); higher follows the prompt more strictly."
|
|
2379
|
+
},
|
|
1627
2380
|
format: {
|
|
1628
2381
|
type: "string",
|
|
1629
2382
|
description: "Output format such as mp3 or wav. MiniMax music supports mp3/wav/pcm."
|
|
@@ -1716,6 +2469,29 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1716
2469
|
required: ["voice_id", "weight"]
|
|
1717
2470
|
},
|
|
1718
2471
|
description: "MiniMax TTS dual-voice blend weights (timbre_weights)."
|
|
2472
|
+
},
|
|
2473
|
+
save_to_library: {
|
|
2474
|
+
type: "boolean",
|
|
2475
|
+
description: "Save the generated audio into the local resource library after success. Also enabled globally by the \"auto save to library\" setting; pass false to skip a single run."
|
|
2476
|
+
},
|
|
2477
|
+
library_name: {
|
|
2478
|
+
type: "string",
|
|
2479
|
+
description: "Resource name in the library. Defaults to the prompt."
|
|
2480
|
+
},
|
|
2481
|
+
library_type: {
|
|
2482
|
+
type: "string",
|
|
2483
|
+
enum: [
|
|
2484
|
+
"voice",
|
|
2485
|
+
"music",
|
|
2486
|
+
"sfx",
|
|
2487
|
+
"tts"
|
|
2488
|
+
],
|
|
2489
|
+
description: "Resource type in the library. Defaults to the generation mode (voice_design → voice)."
|
|
2490
|
+
},
|
|
2491
|
+
library_tags: {
|
|
2492
|
+
type: "array",
|
|
2493
|
+
items: { type: "string" },
|
|
2494
|
+
description: "Tags for the library resource."
|
|
1719
2495
|
}
|
|
1720
2496
|
},
|
|
1721
2497
|
output: {
|
|
@@ -1764,6 +2540,11 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1764
2540
|
...typeof args.duration === "number" ? { duration: args.duration } : {},
|
|
1765
2541
|
...typeof args.lyrics === "string" && args.lyrics.trim() !== "" ? { lyrics: args.lyrics.trim() } : {},
|
|
1766
2542
|
...typeof args.is_instrumental === "boolean" ? { isInstrumental: args.is_instrumental } : {},
|
|
2543
|
+
...typeof args.loop === "boolean" ? { loop: args.loop } : {},
|
|
2544
|
+
...typeof args.prompt_influence === "number" && Number.isFinite(args.prompt_influence) ? { promptInfluence: args.prompt_influence } : {},
|
|
2545
|
+
...typeof args.seed === "number" && Number.isFinite(args.seed) ? { seed: args.seed } : {},
|
|
2546
|
+
...typeof args.steps === "number" && Number.isFinite(args.steps) ? { steps: args.steps } : {},
|
|
2547
|
+
...typeof args.cfg_scale === "number" && Number.isFinite(args.cfg_scale) ? { cfgScale: args.cfg_scale } : {},
|
|
1767
2548
|
...typeof args.format === "string" && args.format.trim() !== "" ? { format: args.format.trim() } : {},
|
|
1768
2549
|
...typeof args.emotion === "string" && args.emotion.trim() !== "" ? { emotion: args.emotion.trim() } : {},
|
|
1769
2550
|
...typeof args.vol === "number" && Number.isFinite(args.vol) ? { vol: args.vol } : {},
|
|
@@ -1784,13 +2565,22 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1784
2565
|
try {
|
|
1785
2566
|
const outputs = await generateAudio(picked.channel, request, exec.signal);
|
|
1786
2567
|
const audio = [];
|
|
2568
|
+
const saved = [];
|
|
1787
2569
|
for (const [index, output] of outputs.entries()) {
|
|
1788
|
-
const
|
|
2570
|
+
const stored = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`);
|
|
2571
|
+
saved.push({
|
|
2572
|
+
id: stored.id,
|
|
2573
|
+
url: `/api/dsh-audiogen/audio/${encodeURIComponent(stored.file)}`,
|
|
2574
|
+
file: stored.file,
|
|
2575
|
+
mime: stored.mime,
|
|
2576
|
+
bytes: stored.bytes,
|
|
2577
|
+
...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
|
|
2578
|
+
});
|
|
1789
2579
|
audio.push({
|
|
1790
|
-
id:
|
|
1791
|
-
url: `/api/dsh-audiogen/audio/${encodeURIComponent(
|
|
1792
|
-
mime:
|
|
1793
|
-
bytes:
|
|
2580
|
+
id: stored.id,
|
|
2581
|
+
url: `/api/dsh-audiogen/audio/${encodeURIComponent(stored.file)}`,
|
|
2582
|
+
mime: stored.mime,
|
|
2583
|
+
bytes: stored.bytes,
|
|
1794
2584
|
...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
|
|
1795
2585
|
});
|
|
1796
2586
|
}
|
|
@@ -1806,23 +2596,52 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1806
2596
|
...request.duration === void 0 ? {} : { duration: request.duration },
|
|
1807
2597
|
...request.format === void 0 ? {} : { format: request.format },
|
|
1808
2598
|
audio: outputs.map((output, index) => ({
|
|
1809
|
-
id:
|
|
2599
|
+
id: saved[index].id,
|
|
2600
|
+
file: saved[index].file,
|
|
1810
2601
|
b64: Buffer.from(output.data).toString("base64"),
|
|
1811
|
-
mime:
|
|
1812
|
-
bytes:
|
|
1813
|
-
url:
|
|
2602
|
+
mime: saved[index].mime,
|
|
2603
|
+
bytes: saved[index].bytes,
|
|
2604
|
+
url: saved[index].url,
|
|
1814
2605
|
...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
|
|
1815
2606
|
})),
|
|
1816
2607
|
channelId: picked.channel.id,
|
|
1817
|
-
channel: picked.channel.name
|
|
2608
|
+
channel: picked.channel.name,
|
|
2609
|
+
params: { ...request }
|
|
1818
2610
|
});
|
|
1819
2611
|
} catch {}
|
|
2612
|
+
const wantSave = args.save_to_library === true || config.autoSaveToLibrary && args.save_to_library !== false;
|
|
2613
|
+
let resources;
|
|
2614
|
+
if (wantSave) try {
|
|
2615
|
+
resources = [(await saveToLibrary({
|
|
2616
|
+
audioFiles: saved.map((item) => ({
|
|
2617
|
+
id: item.id,
|
|
2618
|
+
file: item.file,
|
|
2619
|
+
mime: item.mime,
|
|
2620
|
+
...item.voiceId === void 0 ? {} : { voiceId: item.voiceId }
|
|
2621
|
+
})),
|
|
2622
|
+
type: libraryTypeOf(request.mode, args.library_type),
|
|
2623
|
+
...typeof args.library_name === "string" && args.library_name.trim() !== "" ? { name: args.library_name.trim() } : {},
|
|
2624
|
+
...Array.isArray(args.library_tags) ? { tags: args.library_tags.filter((tag) => typeof tag === "string" && tag.trim() !== "").map((tag) => tag.trim()) } : {},
|
|
2625
|
+
provenance: {
|
|
2626
|
+
mode: request.mode,
|
|
2627
|
+
prompt: request.prompt,
|
|
2628
|
+
channel: picked.channel.name,
|
|
2629
|
+
channelId: picked.channel.id,
|
|
2630
|
+
apiUrl: picked.channel.apiUrl,
|
|
2631
|
+
model: picked.alias,
|
|
2632
|
+
upstream: picked.upstream,
|
|
2633
|
+
...request.voice === void 0 ? {} : { voice: request.voice },
|
|
2634
|
+
params: { ...request }
|
|
2635
|
+
}
|
|
2636
|
+
})).id];
|
|
2637
|
+
} catch {}
|
|
1820
2638
|
return {
|
|
1821
2639
|
status: "completed",
|
|
1822
2640
|
message: "Audio generation completed. The audio files can be played/downloaded from the returned URLs.",
|
|
1823
2641
|
mode: request.mode,
|
|
1824
2642
|
model: picked.alias,
|
|
1825
|
-
audio
|
|
2643
|
+
audio,
|
|
2644
|
+
...resources === void 0 ? {} : { resources }
|
|
1826
2645
|
};
|
|
1827
2646
|
} catch (error) {
|
|
1828
2647
|
if (exec.signal?.aborted === true) throw error;
|
|
@@ -1837,6 +2656,137 @@ function registerAgentAudioTools(ctx, resolve) {
|
|
|
1837
2656
|
}
|
|
1838
2657
|
}
|
|
1839
2658
|
}));
|
|
2659
|
+
const searchDisposer = ctx.tools.register(defineTool({
|
|
2660
|
+
name: "search_audio_library",
|
|
2661
|
+
description: "Search curated audio resources in the local resource library (voice / music / sfx / tts). Returns matching resources with type, category, name, tags, full provenance (channel, model, voiceId, prompt) and same-origin audio URLs the user can play. Use it before generating to reuse an existing voice, music bed or sound effect instead of generating a new one.",
|
|
2662
|
+
parameters: {
|
|
2663
|
+
type: {
|
|
2664
|
+
type: "string",
|
|
2665
|
+
enum: [
|
|
2666
|
+
"voice",
|
|
2667
|
+
"music",
|
|
2668
|
+
"sfx",
|
|
2669
|
+
"tts"
|
|
2670
|
+
],
|
|
2671
|
+
description: "Filter by resource type."
|
|
2672
|
+
},
|
|
2673
|
+
category: {
|
|
2674
|
+
type: "string",
|
|
2675
|
+
description: "Filter by category (voice: male/female/custom; tts: the speaking voice key)."
|
|
2676
|
+
},
|
|
2677
|
+
keyword: {
|
|
2678
|
+
type: "string",
|
|
2679
|
+
description: "Search name, tags, prompt and model."
|
|
2680
|
+
}
|
|
2681
|
+
},
|
|
2682
|
+
output: {
|
|
2683
|
+
schema: {
|
|
2684
|
+
type: "object",
|
|
2685
|
+
additionalProperties: false,
|
|
2686
|
+
properties: {
|
|
2687
|
+
status: {
|
|
2688
|
+
type: "string",
|
|
2689
|
+
required: true,
|
|
2690
|
+
enum: ["ok"]
|
|
2691
|
+
},
|
|
2692
|
+
count: {
|
|
2693
|
+
type: "integer",
|
|
2694
|
+
required: true
|
|
2695
|
+
},
|
|
2696
|
+
entries: {
|
|
2697
|
+
type: "array",
|
|
2698
|
+
required: true,
|
|
2699
|
+
items: {
|
|
2700
|
+
type: "object",
|
|
2701
|
+
additionalProperties: false,
|
|
2702
|
+
properties: {
|
|
2703
|
+
id: {
|
|
2704
|
+
type: "string",
|
|
2705
|
+
required: true
|
|
2706
|
+
},
|
|
2707
|
+
name: {
|
|
2708
|
+
type: "string",
|
|
2709
|
+
required: true
|
|
2710
|
+
},
|
|
2711
|
+
type: {
|
|
2712
|
+
type: "string",
|
|
2713
|
+
required: true,
|
|
2714
|
+
enum: [
|
|
2715
|
+
"voice",
|
|
2716
|
+
"music",
|
|
2717
|
+
"sfx",
|
|
2718
|
+
"tts"
|
|
2719
|
+
]
|
|
2720
|
+
},
|
|
2721
|
+
category: { type: "string" },
|
|
2722
|
+
tags: {
|
|
2723
|
+
type: "array",
|
|
2724
|
+
items: { type: "string" },
|
|
2725
|
+
required: true
|
|
2726
|
+
},
|
|
2727
|
+
prompt: {
|
|
2728
|
+
type: "string",
|
|
2729
|
+
required: true
|
|
2730
|
+
},
|
|
2731
|
+
model: { type: "string" },
|
|
2732
|
+
channel: { type: "string" },
|
|
2733
|
+
voiceId: { type: "string" },
|
|
2734
|
+
urls: {
|
|
2735
|
+
type: "array",
|
|
2736
|
+
items: { type: "string" },
|
|
2737
|
+
required: true
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
},
|
|
2744
|
+
render: (_args, value) => [{
|
|
2745
|
+
type: "text",
|
|
2746
|
+
text: JSON.stringify(value)
|
|
2747
|
+
}]
|
|
2748
|
+
},
|
|
2749
|
+
isConcurrencySafe: () => true,
|
|
2750
|
+
async execute(args) {
|
|
2751
|
+
const keyword = typeof args.keyword === "string" ? args.keyword.trim().toLowerCase() : "";
|
|
2752
|
+
const wantedType = args.type === "voice" || args.type === "music" || args.type === "sfx" || args.type === "tts" ? args.type : void 0;
|
|
2753
|
+
const wantedCategory = typeof args.category === "string" && args.category.trim() !== "" ? args.category.trim() : void 0;
|
|
2754
|
+
const entries = (await listLibrary()).filter((entry) => {
|
|
2755
|
+
if (wantedType !== void 0 && entry.type !== wantedType) return false;
|
|
2756
|
+
if (wantedCategory !== void 0 && (entry.category ?? "") !== wantedCategory) return false;
|
|
2757
|
+
if (keyword !== "") {
|
|
2758
|
+
if (![
|
|
2759
|
+
entry.name,
|
|
2760
|
+
...entry.tags,
|
|
2761
|
+
entry.provenance.prompt,
|
|
2762
|
+
entry.provenance.model ?? "",
|
|
2763
|
+
entry.provenance.channel ?? ""
|
|
2764
|
+
].join(" ").toLowerCase().includes(keyword)) return false;
|
|
2765
|
+
}
|
|
2766
|
+
return true;
|
|
2767
|
+
}).slice(0, 30).map((entry) => ({
|
|
2768
|
+
id: entry.id,
|
|
2769
|
+
name: entry.name,
|
|
2770
|
+
type: entry.type,
|
|
2771
|
+
...entry.category === void 0 ? {} : { category: entry.category },
|
|
2772
|
+
tags: entry.tags,
|
|
2773
|
+
prompt: entry.provenance.prompt,
|
|
2774
|
+
...entry.provenance.model === void 0 ? {} : { model: entry.provenance.model },
|
|
2775
|
+
...entry.provenance.channel === void 0 ? {} : { channel: entry.provenance.channel },
|
|
2776
|
+
...entry.provenance.voiceId === void 0 ? {} : { voiceId: entry.provenance.voiceId },
|
|
2777
|
+
urls: entry.files.map((file) => file.url)
|
|
2778
|
+
}));
|
|
2779
|
+
return {
|
|
2780
|
+
status: "ok",
|
|
2781
|
+
count: entries.length,
|
|
2782
|
+
entries
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
}));
|
|
2786
|
+
return () => {
|
|
2787
|
+
disposer();
|
|
2788
|
+
searchDisposer();
|
|
2789
|
+
};
|
|
1840
2790
|
}
|
|
1841
2791
|
//#endregion
|
|
1842
2792
|
//#region src/index.ts
|
|
@@ -1862,7 +2812,8 @@ const Config = z.object({
|
|
|
1862
2812
|
})).default([]),
|
|
1863
2813
|
channelSecrets: z.dict(z.string().role("secret")).default({}),
|
|
1864
2814
|
defaultChannelId: z.string().default(""),
|
|
1865
|
-
defaultModel: z.string().default("")
|
|
2815
|
+
defaultModel: z.string().default(""),
|
|
2816
|
+
autoSaveToLibrary: z.boolean().default(false)
|
|
1866
2817
|
});
|
|
1867
2818
|
const DEFAULT_ENABLED = true;
|
|
1868
2819
|
const DEFAULT_ANNOUNCE = true;
|
|
@@ -1930,7 +2881,8 @@ function apply(ctx, config) {
|
|
|
1930
2881
|
apiKey: typeof secrets[channel.id] === "string" ? secrets[channel.id] : ""
|
|
1931
2882
|
})),
|
|
1932
2883
|
defaultChannelId,
|
|
1933
|
-
defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : ""
|
|
2884
|
+
defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : "",
|
|
2885
|
+
autoSaveToLibrary: value.autoSaveToLibrary === true
|
|
1934
2886
|
};
|
|
1935
2887
|
};
|
|
1936
2888
|
const channelsView = () => {
|
|
@@ -1945,7 +2897,8 @@ function apply(ctx, config) {
|
|
|
1945
2897
|
sctx.effect(() => {
|
|
1946
2898
|
const disposers = makeRoutes({
|
|
1947
2899
|
settings: seam,
|
|
1948
|
-
resolveChannels: channelsView
|
|
2900
|
+
resolveChannels: channelsView,
|
|
2901
|
+
autoSave: () => resolve().autoSaveToLibrary
|
|
1949
2902
|
}).map((route) => ctx.webServer.register(route));
|
|
1950
2903
|
return () => {
|
|
1951
2904
|
for (const dispose of disposers) dispose();
|
|
@@ -1959,7 +2912,8 @@ function apply(ctx, config) {
|
|
|
1959
2912
|
enabled: value.enabled,
|
|
1960
2913
|
allowAgentAudioGeneration: value.allowAgentAudioGeneration,
|
|
1961
2914
|
channels: value.channels,
|
|
1962
|
-
defaultChannelId: value.defaultChannelId
|
|
2915
|
+
defaultChannelId: value.defaultChannelId,
|
|
2916
|
+
autoSaveToLibrary: value.autoSaveToLibrary
|
|
1963
2917
|
};
|
|
1964
2918
|
}), "dsh-audiogen: agent audio tools");
|
|
1965
2919
|
});
|