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/README.md +7 -1
- package/lib/client.js +2357 -679
- package/lib/client.js.map +1 -1
- package/lib/index.js +883 -45
- package/package.json +1 -1
- package/skills/music/SKILL.md +24 -0
- package/src/agent-audio-tools.ts +155 -14
- package/src/audio-engine.ts +173 -11
- package/src/audio-presets.ts +4 -3
- package/src/audio-store.ts +251 -3
- package/src/client/AudioGenPanel.tsx +59 -376
- 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 +126 -1
- package/src/routes.ts +237 -4
package/src/protocol.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
export const AUDIOGEN_SETTINGS_NAMESPACE = 'dsh-audiogen'
|
|
9
9
|
|
|
10
10
|
/** Published package version shared by the host updater and the client UI. */
|
|
11
|
-
export const PLUGIN_VERSION = '0.
|
|
11
|
+
export const PLUGIN_VERSION = '0.4.0'
|
|
12
12
|
|
|
13
13
|
/** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
|
|
14
14
|
export const SETTINGS_API = {
|
|
@@ -41,12 +41,30 @@ export const HISTORY_API = {
|
|
|
41
41
|
audio: '/api/dsh-audiogen/history/audio',
|
|
42
42
|
} as const
|
|
43
43
|
|
|
44
|
+
/** Host-persisted resource-library routes. */
|
|
45
|
+
export const LIBRARY_API = {
|
|
46
|
+
list: '/api/dsh-audiogen/library/list',
|
|
47
|
+
save: '/api/dsh-audiogen/library/save',
|
|
48
|
+
update: '/api/dsh-audiogen/library/update',
|
|
49
|
+
remove: '/api/dsh-audiogen/library/remove',
|
|
50
|
+
audio: '/api/dsh-audiogen/library/audio',
|
|
51
|
+
} as const
|
|
52
|
+
|
|
44
53
|
/** Maximum number of history entries retained host-side (oldest evicted). */
|
|
45
54
|
export const HISTORY_MAX = 50
|
|
46
55
|
|
|
47
56
|
/** Audio generation modes. */
|
|
48
57
|
export type AudioMode = 'tts' | 'music' | 'sfx' | 'voice_design'
|
|
49
58
|
|
|
59
|
+
/** Resource-library entry kinds (map to directories under library/). */
|
|
60
|
+
export type LibraryType = 'voice' | 'music' | 'sfx' | 'tts'
|
|
61
|
+
|
|
62
|
+
/** Voice resources: gender bucket (male / female / custom). */
|
|
63
|
+
export type VoiceCategory = 'male' | 'female' | 'custom'
|
|
64
|
+
|
|
65
|
+
/** All library types, for iteration and validation. */
|
|
66
|
+
export const LIBRARY_TYPES: readonly LibraryType[] = ['voice', 'music', 'sfx', 'tts'] as const
|
|
67
|
+
|
|
50
68
|
/** The capability category of an audio model/voice. */
|
|
51
69
|
export type AudioModelCategory =
|
|
52
70
|
| 'tts'
|
|
@@ -122,6 +140,12 @@ export interface GenerateAudioRequest {
|
|
|
122
140
|
loop?: boolean
|
|
123
141
|
/** ElevenLabs 音效:提示词影响度 0-1(prompt_influence,默认 0.3)。 */
|
|
124
142
|
promptInfluence?: number
|
|
143
|
+
/** Stable Audio 随机种子(seed,0-4294967294,默认 0=随机)。 */
|
|
144
|
+
seed?: number
|
|
145
|
+
/** Stable Audio 采样步数(steps,按模型收敛:stable-audio-2: 30-100;2.5/3: 4-8)。 */
|
|
146
|
+
steps?: number
|
|
147
|
+
/** Stable Audio 提示词遵循度(cfg_scale 1-25;stable-audio-2 默认 7,2.5/3 默认 1)。 */
|
|
148
|
+
cfgScale?: number
|
|
125
149
|
/** Output format, e.g. mp3, wav, pcm. */
|
|
126
150
|
format?: string
|
|
127
151
|
/** Channel this request targets (host falls back to default). */
|
|
@@ -162,6 +186,8 @@ export interface GenerateAudioRequest {
|
|
|
162
186
|
voiceModify?: { pitch?: number; intensity?: number; timbre?: number; soundEffects?: string }
|
|
163
187
|
/** MiniMax 双音色混合权重(timbre_weights)。 */
|
|
164
188
|
timbreWeights?: Array<{ voiceId: string; weight: number }>
|
|
189
|
+
/** 生成完成后自动保存到资源库(面板勾选;宿主还会并入 autoSaveToLibrary 设置)。 */
|
|
190
|
+
saveToLibrary?: boolean
|
|
165
191
|
}
|
|
166
192
|
|
|
167
193
|
/** One generated audio, normalized host-side to base64. */
|
|
@@ -178,6 +204,8 @@ export interface GeneratedAudio {
|
|
|
178
204
|
url: string
|
|
179
205
|
/** Stable audio id / file name. */
|
|
180
206
|
id: string
|
|
207
|
+
/** File name inside the audio/ store dir. */
|
|
208
|
+
file: string
|
|
181
209
|
/** Optional voice id returned by a voice-design API. */
|
|
182
210
|
voiceId?: string
|
|
183
211
|
}
|
|
@@ -218,6 +246,8 @@ export interface HistoryEntry {
|
|
|
218
246
|
audio: HistoryAudioRef[]
|
|
219
247
|
channelId?: string
|
|
220
248
|
channel?: string
|
|
249
|
+
/** Full resolved generation-request snapshot (provenance; no secrets). */
|
|
250
|
+
params?: Record<string, unknown>
|
|
221
251
|
}
|
|
222
252
|
|
|
223
253
|
/** A history entry the client submits for persistence (audio still carries base64). */
|
|
@@ -235,8 +265,101 @@ export interface HistoryEntryInput {
|
|
|
235
265
|
audio: GeneratedAudio[]
|
|
236
266
|
channelId?: string
|
|
237
267
|
channel?: string
|
|
268
|
+
/** Full resolved generation-request snapshot (provenance; no secrets). */
|
|
269
|
+
params?: Record<string, unknown>
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --------------------------------------------------------------- resource library
|
|
273
|
+
|
|
274
|
+
/** File name / id pair the client hands the host so it can copy a stored audio file. */
|
|
275
|
+
export interface LibraryAudioInput {
|
|
276
|
+
/** Audio id (UUID) as stored in the audio/ dir. */
|
|
277
|
+
id: string
|
|
278
|
+
/** File name inside the audio/ dir (e.g. <uuid>.mp3). */
|
|
279
|
+
file: string
|
|
280
|
+
/** MIME type. */
|
|
281
|
+
mime: string
|
|
282
|
+
/** Optional generated voice id (voice_design outputs). */
|
|
283
|
+
voiceId?: string
|
|
284
|
+
/** Optional duration in seconds. */
|
|
285
|
+
duration?: number
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Full provenance of one library resource. */
|
|
289
|
+
export interface LibraryProvenance {
|
|
290
|
+
mode: AudioMode
|
|
291
|
+
prompt: string
|
|
292
|
+
/** Channel display name snapshot. */
|
|
293
|
+
channel?: string
|
|
294
|
+
channelId?: string
|
|
295
|
+
/** Provider API base URL (non-secret). */
|
|
296
|
+
apiUrl?: string
|
|
297
|
+
/** Model/voice alias as configured. */
|
|
298
|
+
model?: string
|
|
299
|
+
/** Upstream model/voice id actually sent to the provider. */
|
|
300
|
+
upstream?: string
|
|
301
|
+
/** Voice alias used for TTS. */
|
|
302
|
+
voice?: string
|
|
303
|
+
/** voiceId returned by the provider (voice_design). */
|
|
304
|
+
voiceId?: string
|
|
305
|
+
/** Full resolved generation-request snapshot (no secrets). */
|
|
306
|
+
params?: Record<string, unknown>
|
|
238
307
|
}
|
|
239
308
|
|
|
309
|
+
/** One audio file inside a library entry. */
|
|
310
|
+
export interface LibraryFileRef {
|
|
311
|
+
/** Same-origin URL (LIBRARY_API.audio/<rel>). */
|
|
312
|
+
url: string
|
|
313
|
+
/** Relative path under library/ (e.g. voice/male/<id>.mp3). */
|
|
314
|
+
rel: string
|
|
315
|
+
/** MIME type. */
|
|
316
|
+
mime: string
|
|
317
|
+
/** Exact encoded byte length. */
|
|
318
|
+
bytes: number
|
|
319
|
+
/** Duration in seconds when known. */
|
|
320
|
+
duration?: number
|
|
321
|
+
/** Optional generated voice id (voice_design outputs). */
|
|
322
|
+
voiceId?: string
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** One curated resource in the library. */
|
|
326
|
+
export interface LibraryEntry {
|
|
327
|
+
id: string
|
|
328
|
+
createdAt: number
|
|
329
|
+
type: LibraryType
|
|
330
|
+
/** voice: male/female/custom; tts: the speaking voice key; others: ''. */
|
|
331
|
+
category?: string
|
|
332
|
+
name: string
|
|
333
|
+
tags: string[]
|
|
334
|
+
note?: string
|
|
335
|
+
files: LibraryFileRef[]
|
|
336
|
+
provenance: LibraryProvenance
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Client → host save request (audioFiles reference files in the audio/ dir). */
|
|
340
|
+
export interface LibrarySaveRequest {
|
|
341
|
+
audioFiles: LibraryAudioInput[]
|
|
342
|
+
type: LibraryType
|
|
343
|
+
category?: string
|
|
344
|
+
name?: string
|
|
345
|
+
tags?: string[]
|
|
346
|
+
note?: string
|
|
347
|
+
provenance: LibraryProvenance
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Client → host update request (moving type/category relocates files). */
|
|
351
|
+
export interface LibraryUpdateRequest {
|
|
352
|
+
id: string
|
|
353
|
+
name?: string
|
|
354
|
+
tags?: string[]
|
|
355
|
+
note?: string
|
|
356
|
+
category?: string
|
|
357
|
+
type?: LibraryType
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Default name length when no name was given. */
|
|
361
|
+
export const LIBRARY_NAME_MAX = 40
|
|
362
|
+
|
|
240
363
|
/** The plugin settings fields edited by the settings card and panel. */
|
|
241
364
|
export interface AudiogenConfig {
|
|
242
365
|
enabled?: boolean
|
|
@@ -247,4 +370,6 @@ export interface AudiogenConfig {
|
|
|
247
370
|
defaultChannelId?: string
|
|
248
371
|
/** Optional default voice/model alias for quick generation. */
|
|
249
372
|
defaultModel?: string
|
|
373
|
+
/** 生成完成后自动加入资源库(面板与 Agent 生成均生效;单次可取消勾选)。 */
|
|
374
|
+
autoSaveToLibrary?: boolean
|
|
250
375
|
}
|
package/src/routes.ts
CHANGED
|
@@ -13,10 +13,11 @@ import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } fro
|
|
|
13
13
|
import { generateAudio, AudioGenError, type AudioChannel } from './audio-engine.ts'
|
|
14
14
|
import { discoverAudioModels } from './audio-models.ts'
|
|
15
15
|
import { AUDIO_PRESETS } from './audio-presets.ts'
|
|
16
|
-
import { appendHistory, clearHistory, listHistory, readAudioFile, removeHistory, saveAudioFile } from './audio-store.ts'
|
|
16
|
+
import { appendHistory, clearHistory, listHistory, readAudioFile, removeHistory, saveAudioFile, listLibrary, saveToLibrary, updateLibraryEntry, removeLibraryEntries, readLibraryFile } from './audio-store.ts'
|
|
17
17
|
import {
|
|
18
|
-
AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, MODEL_API, PRESETS_API, SETTINGS_API,
|
|
19
|
-
|
|
18
|
+
AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, LIBRARY_API, MODEL_API, PRESETS_API, SETTINGS_API,
|
|
19
|
+
LIBRARY_TYPES,
|
|
20
|
+
type GenerateAudioRequest, type GeneratedAudio, type HistoryEntryInput, type LibraryAudioInput, type LibraryProvenance, type LibraryType,
|
|
20
21
|
} from './protocol.ts'
|
|
21
22
|
|
|
22
23
|
const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
|
|
@@ -38,6 +39,8 @@ export interface ChannelsView {
|
|
|
38
39
|
export interface AudiogenRoutesDeps {
|
|
39
40
|
settings: SettingsSeam
|
|
40
41
|
resolveChannels: () => ChannelsView
|
|
42
|
+
/** Whether the auto-save-to-library setting is on. */
|
|
43
|
+
autoSave: () => boolean
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
@@ -126,6 +129,9 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioReque
|
|
|
126
129
|
...(typeof body.isInstrumental === 'boolean' ? { isInstrumental: body.isInstrumental } : {}),
|
|
127
130
|
...(typeof body.loop === 'boolean' ? { loop: body.loop } : {}),
|
|
128
131
|
...(num(body.promptInfluence) !== undefined ? { promptInfluence: num(body.promptInfluence)! } : {}),
|
|
132
|
+
...(num(body.seed) !== undefined ? { seed: num(body.seed)! } : {}),
|
|
133
|
+
...(num(body.steps) !== undefined ? { steps: num(body.steps)! } : {}),
|
|
134
|
+
...(num(body.cfgScale) !== undefined ? { cfgScale: num(body.cfgScale)! } : {}),
|
|
129
135
|
...(typeof body.format === 'string' && body.format.trim() !== '' ? { format: body.format.trim() } : {}),
|
|
130
136
|
...(typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {}),
|
|
131
137
|
// ---- MiniMax TTS 专属字段(其他厂商忽略) ----
|
|
@@ -144,6 +150,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioReque
|
|
|
144
150
|
...(str(body.languageBoost) !== undefined ? { languageBoost: str(body.languageBoost)! } : {}),
|
|
145
151
|
...(voiceModify !== undefined ? { voiceModify } : {}),
|
|
146
152
|
...(timbreWeights !== undefined && timbreWeights.length > 0 ? { timbreWeights } : {}),
|
|
153
|
+
...(flag(body.saveToLibrary) !== undefined ? { saveToLibrary: flag(body.saveToLibrary)! } : {}),
|
|
147
154
|
}
|
|
148
155
|
}
|
|
149
156
|
|
|
@@ -208,6 +215,74 @@ function resolveChannelRequest(
|
|
|
208
215
|
return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
|
|
209
216
|
}
|
|
210
217
|
|
|
218
|
+
/** Build the library type from a generation mode (voice_design → voice). */
|
|
219
|
+
function libraryTypeOf(mode: GenerateAudioRequest['mode']): LibraryType {
|
|
220
|
+
if (mode === 'voice_design') return 'voice'
|
|
221
|
+
return mode
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Provenance snapshot straight from a resolved generate request. */
|
|
225
|
+
function provenanceOf(request: GenerateAudioRequest, apiUrl: string): LibraryProvenance {
|
|
226
|
+
return {
|
|
227
|
+
mode: request.mode,
|
|
228
|
+
prompt: request.prompt,
|
|
229
|
+
...(request.channel === undefined ? {} : { channel: request.channel }),
|
|
230
|
+
...(request.channelId === undefined ? {} : { channelId: request.channelId }),
|
|
231
|
+
...(apiUrl === '' ? {} : { apiUrl }),
|
|
232
|
+
...(request.model === undefined || request.model === '' ? {} : { model: request.model }),
|
|
233
|
+
...(request.upstream === undefined || request.upstream === '' ? {} : { upstream: request.upstream }),
|
|
234
|
+
...(request.voice === undefined ? {} : { voice: request.voice }),
|
|
235
|
+
params: { ...request },
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const strOf = (value: unknown): string | undefined => typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
240
|
+
const strListOf = (value: unknown): string[] | undefined =>
|
|
241
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map(item => item.trim()) : undefined
|
|
242
|
+
const parseModeOf = (value: unknown): GenerateAudioRequest['mode'] =>
|
|
243
|
+
value === 'music' ? 'music' : value === 'sfx' ? 'sfx' : value === 'voice_design' ? 'voice_design' : 'tts'
|
|
244
|
+
const parseLibraryTypeOf = (value: unknown): LibraryType | undefined =>
|
|
245
|
+
(LIBRARY_TYPES as readonly unknown[]).includes(value) ? value as LibraryType : undefined
|
|
246
|
+
|
|
247
|
+
/** File name (audio/ id.ext) from a same-origin audio url. */
|
|
248
|
+
function historyFileIdOf(url: string): string {
|
|
249
|
+
try {
|
|
250
|
+
return decodeURIComponent(new URL(url, 'http://localhost').pathname.split('/').pop() ?? '')
|
|
251
|
+
} catch {
|
|
252
|
+
return ''
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Fill missing provenance fields from host-persisted history (which carries
|
|
258
|
+
* the resolved request snapshot) and the channel catalog. Client-supplied
|
|
259
|
+
* values win when present.
|
|
260
|
+
*/
|
|
261
|
+
async function mergeLibraryProvenance(
|
|
262
|
+
given: LibraryProvenance,
|
|
263
|
+
files: LibraryAudioInput[],
|
|
264
|
+
channels: AudioChannel[],
|
|
265
|
+
): Promise<LibraryProvenance> {
|
|
266
|
+
const wanted = new Set(files.map(file => file.file))
|
|
267
|
+
const history = await listHistory()
|
|
268
|
+
const entry = history.find(candidate => candidate.audio.some(audio => wanted.has(historyFileIdOf(audio.url))))
|
|
269
|
+
const params = entry?.params !== undefined && typeof entry.params === 'object' ? entry.params : undefined
|
|
270
|
+
const channel = channels.find(candidate => candidate.id === (entry?.channelId ?? ''))
|
|
271
|
+
return {
|
|
272
|
+
mode: entry?.mode ?? given.mode,
|
|
273
|
+
prompt: given.prompt !== '' ? given.prompt : (entry?.prompt ?? ''),
|
|
274
|
+
...(given.channel !== undefined || entry?.channel !== undefined ? { channel: given.channel ?? entry?.channel } : {}),
|
|
275
|
+
...(given.channelId !== undefined || entry?.channelId !== undefined ? { channelId: given.channelId ?? entry?.channelId } : {}),
|
|
276
|
+
...((given.apiUrl ?? channel?.apiUrl ?? '') === '' ? {} : { apiUrl: given.apiUrl ?? channel?.apiUrl }),
|
|
277
|
+
...(given.model !== undefined || entry?.model !== undefined ? { model: given.model ?? entry?.model } : {}),
|
|
278
|
+
...((given.upstream ?? (typeof params?.upstream === 'string' ? params.upstream : undefined)) !== undefined
|
|
279
|
+
? { upstream: given.upstream ?? (typeof params?.upstream === 'string' ? params.upstream : undefined) } : {}),
|
|
280
|
+
...(given.voice !== undefined || entry?.voice !== undefined ? { voice: given.voice ?? entry?.voice } : {}),
|
|
281
|
+
...(given.voiceId !== undefined || entry?.voiceId !== undefined ? { voiceId: given.voiceId ?? entry?.voiceId } : {}),
|
|
282
|
+
...(given.params !== undefined || params !== undefined ? { params: given.params ?? params } : {}),
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
211
286
|
/** Build every /api/dsh-audiogen route. */
|
|
212
287
|
export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
213
288
|
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
@@ -348,6 +423,7 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
|
348
423
|
const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
|
|
349
424
|
generated.push({
|
|
350
425
|
id: saved.id,
|
|
426
|
+
file: saved.file,
|
|
351
427
|
b64: Buffer.from(output.data).toString('base64'),
|
|
352
428
|
mime: saved.mime,
|
|
353
429
|
bytes: saved.bytes,
|
|
@@ -355,6 +431,7 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
|
355
431
|
...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
|
|
356
432
|
})
|
|
357
433
|
}
|
|
434
|
+
const paramsSnapshot: Record<string, unknown> = { ...request }
|
|
358
435
|
let history
|
|
359
436
|
try {
|
|
360
437
|
history = await appendHistory({
|
|
@@ -370,12 +447,33 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
|
370
447
|
audio: generated,
|
|
371
448
|
...(request.channelId === undefined ? {} : { channelId: request.channelId }),
|
|
372
449
|
...(request.channel === undefined ? {} : { channel: request.channel }),
|
|
450
|
+
params: paramsSnapshot,
|
|
373
451
|
})
|
|
374
452
|
} catch (error) {
|
|
375
453
|
writeJson(res, 200, { ok: true, outputs: generated, historyError: messageOf(error) })
|
|
376
454
|
return
|
|
377
455
|
}
|
|
378
|
-
|
|
456
|
+
// ---- 资源库:单次勾选或设置自动入库(saveToLibrary === false 显式跳过) ----
|
|
457
|
+
const wantSave = request.saveToLibrary === true || (deps.autoSave() && request.saveToLibrary !== false)
|
|
458
|
+
let resources: Array<{ id: string; name: string; type: LibraryType }> | undefined
|
|
459
|
+
if (wantSave) {
|
|
460
|
+
try {
|
|
461
|
+
const entry = await saveToLibrary({
|
|
462
|
+
audioFiles: generated.map(audio => ({
|
|
463
|
+
id: audio.id,
|
|
464
|
+
file: audio.file,
|
|
465
|
+
mime: audio.mime,
|
|
466
|
+
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
467
|
+
})),
|
|
468
|
+
type: libraryTypeOf(request.mode),
|
|
469
|
+
provenance: provenanceOf(request, channel.apiUrl),
|
|
470
|
+
})
|
|
471
|
+
resources = [{ id: entry.id, name: entry.name, type: entry.type }]
|
|
472
|
+
} catch {
|
|
473
|
+
// library-save is best-effort: generation and history already succeeded
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
writeJson(res, 200, { ok: true, outputs: generated, history, ...(resources === undefined ? {} : { resources }) })
|
|
379
477
|
} catch (error) {
|
|
380
478
|
const code = error instanceof AudioGenError ? error.code : 'generate-failed'
|
|
381
479
|
writeJson(res, 200, { ok: false, code, message: messageOf(error) })
|
|
@@ -413,6 +511,141 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
|
413
511
|
res.end(stored.data)
|
|
414
512
|
},
|
|
415
513
|
},
|
|
514
|
+
// ------------------------------------------------------- resource library
|
|
515
|
+
{
|
|
516
|
+
kind: 'exact', path: LIBRARY_API.list,
|
|
517
|
+
handler: async (req, res) => {
|
|
518
|
+
if (!guard(req, res, 'POST')) return
|
|
519
|
+
writeJson(res, 200, { ok: true, entries: await listLibrary() })
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
kind: 'exact', path: LIBRARY_API.save,
|
|
524
|
+
handler: async (req, res) => {
|
|
525
|
+
if (!guard(req, res, 'POST')) return
|
|
526
|
+
const body = await readJsonBody(req)
|
|
527
|
+
const audioFiles: LibraryAudioInput[] = Array.isArray(body?.audioFiles)
|
|
528
|
+
? body.audioFiles
|
|
529
|
+
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
530
|
+
.map(item => ({
|
|
531
|
+
id: strOf(item.id) ?? '',
|
|
532
|
+
file: strOf(item.file) ?? '',
|
|
533
|
+
mime: strOf(item.mime) ?? 'audio/mpeg',
|
|
534
|
+
...(strOf(item.voiceId) !== undefined ? { voiceId: strOf(item.voiceId)! } : {}),
|
|
535
|
+
...(typeof item.duration === 'number' && Number.isFinite(item.duration) ? { duration: item.duration } : {}),
|
|
536
|
+
}))
|
|
537
|
+
.filter(item => item.id !== '' && item.file !== '')
|
|
538
|
+
: []
|
|
539
|
+
if (audioFiles.length === 0) {
|
|
540
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: '没有可入库的音频文件' })
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
const type = parseLibraryTypeOf(body?.type)
|
|
544
|
+
if (type === undefined) {
|
|
545
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: '资源类型无效(voice/music/sfx/tts)' })
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
const rawProvenance = typeof body?.provenance === 'object' && body.provenance !== null ? body.provenance as Record<string, unknown> : {}
|
|
549
|
+
const provenance = await mergeLibraryProvenance({
|
|
550
|
+
mode: parseModeOf(rawProvenance.mode),
|
|
551
|
+
prompt: typeof rawProvenance.prompt === 'string' ? rawProvenance.prompt.trim() : '',
|
|
552
|
+
...(strOf(rawProvenance.channel) !== undefined ? { channel: strOf(rawProvenance.channel)! } : {}),
|
|
553
|
+
...(strOf(rawProvenance.channelId) !== undefined ? { channelId: strOf(rawProvenance.channelId)! } : {}),
|
|
554
|
+
...(strOf(rawProvenance.apiUrl) !== undefined ? { apiUrl: strOf(rawProvenance.apiUrl)! } : {}),
|
|
555
|
+
...(strOf(rawProvenance.model) !== undefined ? { model: strOf(rawProvenance.model)! } : {}),
|
|
556
|
+
...(strOf(rawProvenance.upstream) !== undefined ? { upstream: strOf(rawProvenance.upstream)! } : {}),
|
|
557
|
+
...(strOf(rawProvenance.voice) !== undefined ? { voice: strOf(rawProvenance.voice)! } : {}),
|
|
558
|
+
...(strOf(rawProvenance.voiceId) !== undefined ? { voiceId: strOf(rawProvenance.voiceId)! } : {}),
|
|
559
|
+
...(typeof rawProvenance.params === 'object' && rawProvenance.params !== null
|
|
560
|
+
? { params: rawProvenance.params as Record<string, unknown> } : {}),
|
|
561
|
+
}, audioFiles, deps.resolveChannels().channels)
|
|
562
|
+
try {
|
|
563
|
+
const entry = await saveToLibrary({
|
|
564
|
+
audioFiles,
|
|
565
|
+
type,
|
|
566
|
+
...(strOf(body?.category) !== undefined ? { category: strOf(body?.category)! } : {}),
|
|
567
|
+
...(strOf(body?.name) !== undefined ? { name: strOf(body?.name)! } : {}),
|
|
568
|
+
...(strListOf(body?.tags) !== undefined ? { tags: strListOf(body?.tags)! } : {}),
|
|
569
|
+
...(strOf(body?.note) !== undefined ? { note: strOf(body?.note)! } : {}),
|
|
570
|
+
provenance,
|
|
571
|
+
})
|
|
572
|
+
writeJson(res, 200, { ok: true, entry })
|
|
573
|
+
} catch (error) {
|
|
574
|
+
writeJson(res, 200, { ok: false, code: 'library-save-failed', message: messageOf(error) })
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
kind: 'exact', path: LIBRARY_API.update,
|
|
580
|
+
handler: async (req, res) => {
|
|
581
|
+
if (!guard(req, res, 'POST')) return
|
|
582
|
+
const body = await readJsonBody(req)
|
|
583
|
+
const id = strOf(body?.id)
|
|
584
|
+
if (id === undefined) {
|
|
585
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: '缺少资源 id' })
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
const entry = await updateLibraryEntry(id, {
|
|
590
|
+
...(strOf(body?.name) !== undefined ? { name: strOf(body?.name)! } : {}),
|
|
591
|
+
...(strListOf(body?.tags) !== undefined ? { tags: strListOf(body?.tags)! } : {}),
|
|
592
|
+
...(typeof body?.note === 'string' ? { note: body.note } : {}),
|
|
593
|
+
...(strOf(body?.category) !== undefined ? { category: strOf(body?.category)! } : {}),
|
|
594
|
+
...(parseLibraryTypeOf(body?.type) !== undefined ? { type: parseLibraryTypeOf(body?.type)! } : {}),
|
|
595
|
+
})
|
|
596
|
+
if (entry === undefined) {
|
|
597
|
+
writeJson(res, 200, { ok: false, code: 'not-found', message: '资源不存在' })
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
writeJson(res, 200, { ok: true, entry })
|
|
601
|
+
} catch (error) {
|
|
602
|
+
writeJson(res, 200, { ok: false, code: 'library-update-failed', message: messageOf(error) })
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
kind: 'exact', path: LIBRARY_API.remove,
|
|
608
|
+
handler: async (req, res) => {
|
|
609
|
+
if (!guard(req, res, 'POST')) return
|
|
610
|
+
const body = await readJsonBody(req)
|
|
611
|
+
const ids = strListOf(body?.ids) ?? []
|
|
612
|
+
if (ids.length === 0) {
|
|
613
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: '缺少资源 id' })
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const entries = await removeLibraryEntries(ids)
|
|
618
|
+
writeJson(res, 200, { ok: true, entries })
|
|
619
|
+
} catch (error) {
|
|
620
|
+
writeJson(res, 200, { ok: false, code: 'library-remove-failed', message: messageOf(error) })
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
kind: 'prefix', path: LIBRARY_API.audio,
|
|
626
|
+
handler: async (req, res) => {
|
|
627
|
+
if (!isLoopbackRequest(req)) {
|
|
628
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
if (req.method !== 'GET') {
|
|
632
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
const rel = audioFileFrom(req.url, LIBRARY_API.audio)
|
|
636
|
+
if (rel === undefined) {
|
|
637
|
+
writeJson(res, 400, { error: 'invalid library audio' })
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
const stored = await readLibraryFile(rel)
|
|
641
|
+
if (stored === undefined) {
|
|
642
|
+
writeJson(res, 404, { error: 'library audio not found' })
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
res.writeHead(200, { 'content-type': stored.mime, 'content-length': stored.bytes, 'cache-control': 'private, max-age=3600' })
|
|
646
|
+
res.end(stored.data)
|
|
647
|
+
},
|
|
648
|
+
},
|
|
416
649
|
// ------------------------------------------------------- history
|
|
417
650
|
{
|
|
418
651
|
kind: 'exact', path: HISTORY_API.list,
|