dsh-audiogen 0.3.5 → 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/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
- return value.split(";")[0].trim().toLowerCase();
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";
@@ -591,30 +611,155 @@ async function minimax(channel, request, signal) {
591
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");
592
612
  }
593
613
  }
594
- async function stabilityAudio(channel, request, signal) {
595
- const base = endpointBase(channel.apiUrl);
596
- const endpoint = /\/generation(\?|$)/i.test(base) ? base : `${base}/generation`;
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";
597
716
  const body = {
598
- model: (request.upstream ?? request.model) || "stable-audio-2.0",
599
- prompt: request.prompt,
600
- ...request.duration !== void 0 ? { duration: request.duration } : {},
601
- ...request.format !== void 0 ? { output_format: request.format } : {}
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)) } : {}
602
724
  };
603
- return normalizeAudioResponse(await fetchWithTimeout(endpoint, {
725
+ const response = await fetchWithTimeout(endpoint, {
604
726
  method: "POST",
605
- redirect: "error",
727
+ redirect: "follow",
606
728
  headers: {
607
729
  authorization: `Bearer ${channel.apiKey.trim()}`,
608
- "content-type": "application/json",
609
- accept: "application/json, audio/mpeg, audio/wav"
730
+ accept: "audio/*",
731
+ "content-type": "application/json"
610
732
  },
611
733
  body: JSON.stringify(body),
612
734
  signal
613
- }, 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, {
614
742
  apiKey: channel.apiKey,
615
743
  fallbackMime: "audio/mpeg"
616
744
  });
617
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
+ }
618
763
  async function genericAudio(channel, request, signal) {
619
764
  const base = endpointBase(channel.apiUrl);
620
765
  if (request.mode === "tts" && !/\/generate(\?|$)/i.test(base)) return openAITTS(channel, request, signal);
@@ -653,7 +798,7 @@ async function generateAudio(channel, request, signal) {
653
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");
654
799
  if (isElevenLabs$1(channel)) return elevenLabs(channel, request, signal);
655
800
  if (isMiniMax$1(channel)) return minimax(channel, request, signal);
656
- 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);
657
802
  if (isOpenAICompatible(channel, request.mode)) return openAITTS(channel, request, signal);
658
803
  return genericAudio(channel, request, signal);
659
804
  }
@@ -788,16 +933,24 @@ const AUDIO_PRESETS = [
788
933
  name: "Stability AI(stable-audio)",
789
934
  apiUrl: "https://api.stability.ai/v2beta/audio",
790
935
  site: "https://stability.ai/stable-audio",
791
- hint: "Stability AI 音乐 / 音效生成(stable-audio 系列)",
792
- models: [{
793
- alias: "stable-audio-2.0",
794
- id: "stable-audio-2.0",
795
- category: "music"
796
- }, {
797
- alias: "stable-audio-1.0",
798
- id: "stable-audio-1.0",
799
- category: "music"
800
- }]
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
+ ]
801
954
  }
802
955
  ];
803
956
  /** Look up one built-in provider by id. */
@@ -995,12 +1148,16 @@ function dedupe(models) {
995
1148
  /**
996
1149
  * Host-side persistence for generated audio and generation history.
997
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.
998
1153
  */
999
1154
  function dshHome() {
1000
1155
  return process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh");
1001
1156
  }
1002
1157
  const AUDIO_DATA_DIR = path.join(dshHome(), "dsh-audiogen", "audio");
1003
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");
1004
1161
  async function ensureDir() {
1005
1162
  await mkdir(AUDIO_DATA_DIR, { recursive: true });
1006
1163
  }
@@ -1083,7 +1240,8 @@ async function appendHistory(entry) {
1083
1240
  ...audio.voiceId === void 0 ? {} : { voiceId: audio.voiceId }
1084
1241
  })),
1085
1242
  ...entry.channelId === void 0 ? {} : { channelId: entry.channelId },
1086
- ...entry.channel === void 0 ? {} : { channel: entry.channel }
1243
+ ...entry.channel === void 0 ? {} : { channel: entry.channel },
1244
+ ...entry.params === void 0 ? {} : { params: entry.params }
1087
1245
  }, ...list].slice(0, 50);
1088
1246
  await writeHistory(next);
1089
1247
  return next;
@@ -1100,6 +1258,205 @@ async function clearHistory() {
1100
1258
  await writeHistory([]);
1101
1259
  return [];
1102
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
+ ];
1103
1460
  //#endregion
1104
1461
  //#region src/routes.ts
1105
1462
  const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
@@ -1182,6 +1539,9 @@ function parseGenerateRequest(body) {
1182
1539
  ...typeof body.isInstrumental === "boolean" ? { isInstrumental: body.isInstrumental } : {},
1183
1540
  ...typeof body.loop === "boolean" ? { loop: body.loop } : {},
1184
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) } : {},
1185
1545
  ...typeof body.format === "string" && body.format.trim() !== "" ? { format: body.format.trim() } : {},
1186
1546
  ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {},
1187
1547
  ...str(body.emotion) !== void 0 ? { emotion: str(body.emotion) } : {},
@@ -1198,7 +1558,8 @@ function parseGenerateRequest(body) {
1198
1558
  ...flag(body.aigcWatermark) !== void 0 ? { aigcWatermark: flag(body.aigcWatermark) } : {},
1199
1559
  ...str(body.languageBoost) !== void 0 ? { languageBoost: str(body.languageBoost) } : {},
1200
1560
  ...voiceModify !== void 0 ? { voiceModify } : {},
1201
- ...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) } : {}
1202
1563
  };
1203
1564
  }
1204
1565
  function toView(descriptor) {
@@ -1295,6 +1656,60 @@ function resolveChannelRequest(request, view) {
1295
1656
  }
1296
1657
  };
1297
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
+ }
1298
1713
  /** Build every /api/dsh-audiogen route. */
1299
1714
  function makeRoutes(deps) {
1300
1715
  const guard = (req, res, method) => {
@@ -1455,6 +1870,7 @@ function makeRoutes(deps) {
1455
1870
  const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`);
1456
1871
  generated.push({
1457
1872
  id: saved.id,
1873
+ file: saved.file,
1458
1874
  b64: Buffer.from(output.data).toString("base64"),
1459
1875
  mime: saved.mime,
1460
1876
  bytes: saved.bytes,
@@ -1462,6 +1878,7 @@ function makeRoutes(deps) {
1462
1878
  ...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
1463
1879
  });
1464
1880
  }
1881
+ const paramsSnapshot = { ...request };
1465
1882
  let history;
1466
1883
  try {
1467
1884
  history = await appendHistory({
@@ -1476,7 +1893,8 @@ function makeRoutes(deps) {
1476
1893
  ...request.format === void 0 ? {} : { format: request.format },
1477
1894
  audio: generated,
1478
1895
  ...request.channelId === void 0 ? {} : { channelId: request.channelId },
1479
- ...request.channel === void 0 ? {} : { channel: request.channel }
1896
+ ...request.channel === void 0 ? {} : { channel: request.channel },
1897
+ params: paramsSnapshot
1480
1898
  });
1481
1899
  } catch (error) {
1482
1900
  writeJson(res, 200, {
@@ -1486,10 +1904,30 @@ function makeRoutes(deps) {
1486
1904
  });
1487
1905
  return;
1488
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 {}
1489
1926
  writeJson(res, 200, {
1490
1927
  ok: true,
1491
1928
  outputs: generated,
1492
- history
1929
+ history,
1930
+ ...resources === void 0 ? {} : { resources }
1493
1931
  });
1494
1932
  } catch (error) {
1495
1933
  writeJson(res, 200, {
@@ -1530,6 +1968,185 @@ function makeRoutes(deps) {
1530
1968
  res.end(stored.data);
1531
1969
  }
1532
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
+ },
1533
2150
  {
1534
2151
  kind: "exact",
1535
2152
  path: HISTORY_API.list,
@@ -1651,6 +2268,10 @@ const resultSchema = {
1651
2268
  }
1652
2269
  }
1653
2270
  },
2271
+ resources: {
2272
+ type: "array",
2273
+ items: { type: "string" }
2274
+ },
1654
2275
  error: { type: "string" }
1655
2276
  }
1656
2277
  };
@@ -1681,9 +2302,15 @@ function ensureConfigured(config) {
1681
2302
  if (!config.allowAgentAudioGeneration) throw new AudioGenError("Agent audio generation is disabled in Settings > Plugins > AI Audio.", "agent-generation-disabled");
1682
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");
1683
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
+ }
1684
2311
  /** Register the Agent audio tool. */
1685
2312
  function registerAgentAudioTools(ctx, resolve) {
1686
- return ctx.tools.register(defineTool({
2313
+ const disposer = ctx.tools.register(defineTool({
1687
2314
  name: "generate_audio",
1688
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.",
1689
2316
  parameters: {
@@ -1738,6 +2365,18 @@ function registerAgentAudioTools(ctx, resolve) {
1738
2365
  type: "number",
1739
2366
  description: "Sound effect prompt influence 0-1 (ElevenLabs prompt_influence, default 0.3): higher follows the prompt more closely, lower is more variable."
1740
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
+ },
1741
2380
  format: {
1742
2381
  type: "string",
1743
2382
  description: "Output format such as mp3 or wav. MiniMax music supports mp3/wav/pcm."
@@ -1830,6 +2469,29 @@ function registerAgentAudioTools(ctx, resolve) {
1830
2469
  required: ["voice_id", "weight"]
1831
2470
  },
1832
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."
1833
2495
  }
1834
2496
  },
1835
2497
  output: {
@@ -1880,6 +2542,9 @@ function registerAgentAudioTools(ctx, resolve) {
1880
2542
  ...typeof args.is_instrumental === "boolean" ? { isInstrumental: args.is_instrumental } : {},
1881
2543
  ...typeof args.loop === "boolean" ? { loop: args.loop } : {},
1882
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 } : {},
1883
2548
  ...typeof args.format === "string" && args.format.trim() !== "" ? { format: args.format.trim() } : {},
1884
2549
  ...typeof args.emotion === "string" && args.emotion.trim() !== "" ? { emotion: args.emotion.trim() } : {},
1885
2550
  ...typeof args.vol === "number" && Number.isFinite(args.vol) ? { vol: args.vol } : {},
@@ -1900,13 +2565,22 @@ function registerAgentAudioTools(ctx, resolve) {
1900
2565
  try {
1901
2566
  const outputs = await generateAudio(picked.channel, request, exec.signal);
1902
2567
  const audio = [];
2568
+ const saved = [];
1903
2569
  for (const [index, output] of outputs.entries()) {
1904
- const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`);
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
+ });
1905
2579
  audio.push({
1906
- id: saved.id,
1907
- url: `/api/dsh-audiogen/audio/${encodeURIComponent(saved.file)}`,
1908
- mime: saved.mime,
1909
- bytes: saved.bytes,
2580
+ id: stored.id,
2581
+ url: `/api/dsh-audiogen/audio/${encodeURIComponent(stored.file)}`,
2582
+ mime: stored.mime,
2583
+ bytes: stored.bytes,
1910
2584
  ...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
1911
2585
  });
1912
2586
  }
@@ -1922,23 +2596,52 @@ function registerAgentAudioTools(ctx, resolve) {
1922
2596
  ...request.duration === void 0 ? {} : { duration: request.duration },
1923
2597
  ...request.format === void 0 ? {} : { format: request.format },
1924
2598
  audio: outputs.map((output, index) => ({
1925
- id: audio[index].id,
2599
+ id: saved[index].id,
2600
+ file: saved[index].file,
1926
2601
  b64: Buffer.from(output.data).toString("base64"),
1927
- mime: audio[index].mime,
1928
- bytes: audio[index].bytes,
1929
- url: audio[index].url,
2602
+ mime: saved[index].mime,
2603
+ bytes: saved[index].bytes,
2604
+ url: saved[index].url,
1930
2605
  ...output.voiceId === void 0 ? {} : { voiceId: output.voiceId }
1931
2606
  })),
1932
2607
  channelId: picked.channel.id,
1933
- channel: picked.channel.name
2608
+ channel: picked.channel.name,
2609
+ params: { ...request }
1934
2610
  });
1935
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 {}
1936
2638
  return {
1937
2639
  status: "completed",
1938
2640
  message: "Audio generation completed. The audio files can be played/downloaded from the returned URLs.",
1939
2641
  mode: request.mode,
1940
2642
  model: picked.alias,
1941
- audio
2643
+ audio,
2644
+ ...resources === void 0 ? {} : { resources }
1942
2645
  };
1943
2646
  } catch (error) {
1944
2647
  if (exec.signal?.aborted === true) throw error;
@@ -1953,6 +2656,137 @@ function registerAgentAudioTools(ctx, resolve) {
1953
2656
  }
1954
2657
  }
1955
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
+ };
1956
2790
  }
1957
2791
  //#endregion
1958
2792
  //#region src/index.ts
@@ -1978,7 +2812,8 @@ const Config = z.object({
1978
2812
  })).default([]),
1979
2813
  channelSecrets: z.dict(z.string().role("secret")).default({}),
1980
2814
  defaultChannelId: z.string().default(""),
1981
- defaultModel: z.string().default("")
2815
+ defaultModel: z.string().default(""),
2816
+ autoSaveToLibrary: z.boolean().default(false)
1982
2817
  });
1983
2818
  const DEFAULT_ENABLED = true;
1984
2819
  const DEFAULT_ANNOUNCE = true;
@@ -2046,7 +2881,8 @@ function apply(ctx, config) {
2046
2881
  apiKey: typeof secrets[channel.id] === "string" ? secrets[channel.id] : ""
2047
2882
  })),
2048
2883
  defaultChannelId,
2049
- defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : ""
2884
+ defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : "",
2885
+ autoSaveToLibrary: value.autoSaveToLibrary === true
2050
2886
  };
2051
2887
  };
2052
2888
  const channelsView = () => {
@@ -2061,7 +2897,8 @@ function apply(ctx, config) {
2061
2897
  sctx.effect(() => {
2062
2898
  const disposers = makeRoutes({
2063
2899
  settings: seam,
2064
- resolveChannels: channelsView
2900
+ resolveChannels: channelsView,
2901
+ autoSave: () => resolve().autoSaveToLibrary
2065
2902
  }).map((route) => ctx.webServer.register(route));
2066
2903
  return () => {
2067
2904
  for (const dispose of disposers) dispose();
@@ -2075,7 +2912,8 @@ function apply(ctx, config) {
2075
2912
  enabled: value.enabled,
2076
2913
  allowAgentAudioGeneration: value.allowAgentAudioGeneration,
2077
2914
  channels: value.channels,
2078
- defaultChannelId: value.defaultChannelId
2915
+ defaultChannelId: value.defaultChannelId,
2916
+ autoSaveToLibrary: value.autoSaveToLibrary
2079
2917
  };
2080
2918
  }), "dsh-audiogen: agent audio tools");
2081
2919
  });