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.
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * Host-side persistence for generated audio and generation history.
3
3
  * Files live under ~/.dsh/dsh-audiogen/audio/; history is one JSON document.
4
+ * The resource library lives under ~/.dsh/dsh-audiogen/library/ with one
5
+ * index JSON plus files organized by type (voice/music/sfx/tts) and category.
4
6
  */
5
7
 
6
- import { mkdir, readFile, writeFile, readdir, unlink } from 'node:fs/promises'
8
+ import { mkdir, readFile, writeFile, unlink, rename, rmdir } from 'node:fs/promises'
7
9
  import { randomUUID } from 'node:crypto'
8
10
  import path from 'node:path'
9
11
  import os from 'node:os'
10
- import type { HistoryEntry, HistoryEntryInput } from './protocol.ts'
11
- import { HISTORY_MAX } from './protocol.ts'
12
+ import type { HistoryEntry, HistoryEntryInput, LibraryEntry, LibraryType, LibraryFileRef, LibraryAudioInput, LibraryProvenance } from './protocol.ts'
13
+ import { HISTORY_MAX, LIBRARY_API, LIBRARY_NAME_MAX } from './protocol.ts'
12
14
 
13
15
  function dshHome(): string {
14
16
  return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
@@ -16,6 +18,8 @@ function dshHome(): string {
16
18
 
17
19
  export const AUDIO_DATA_DIR = path.join(dshHome(), 'dsh-audiogen', 'audio')
18
20
  const HISTORY_FILE = path.join(dshHome(), 'dsh-audiogen', 'history.json')
21
+ export const LIBRARY_DATA_DIR = path.join(dshHome(), 'dsh-audiogen', 'library')
22
+ const LIBRARY_INDEX_FILE = path.join(LIBRARY_DATA_DIR, 'index.json')
19
23
 
20
24
  async function ensureDir(): Promise<void> {
21
25
  await mkdir(AUDIO_DATA_DIR, { recursive: true })
@@ -111,6 +115,7 @@ export async function appendHistory(entry: HistoryEntryInput): Promise<HistoryEn
111
115
  })),
112
116
  ...(entry.channelId === undefined ? {} : { channelId: entry.channelId }),
113
117
  ...(entry.channel === undefined ? {} : { channel: entry.channel }),
118
+ ...(entry.params === undefined ? {} : { params: entry.params }),
114
119
  }, ...list].slice(0, HISTORY_MAX)
115
120
  await writeHistory(next)
116
121
  return next
@@ -131,3 +136,246 @@ export async function clearHistory(): Promise<HistoryEntry[]> {
131
136
  await writeHistory([])
132
137
  return []
133
138
  }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Resource library
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** Library type dir names (whitelisted on the audio route too). */
145
+ const LIBRARY_TYPE_DIRS: Record<LibraryType, string> = {
146
+ voice: 'voice',
147
+ music: 'music',
148
+ sfx: 'sfx',
149
+ tts: 'tts',
150
+ }
151
+
152
+ /** Sanitize one path segment (cid or voice key). Falls back to 'default'. */
153
+ export function sanitizeSegment(value: string): string {
154
+ const cleaned = value
155
+ .replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]+/g, '_')
156
+ .replace(/^[._-]+|[._-]+$/g, '')
157
+ .slice(0, 60)
158
+ return cleaned === '' ? 'default' : cleaned
159
+ }
160
+
161
+ /** Infer the category for a save when the client did not provide one. */
162
+ export function defaultLibraryCategory(
163
+ type: LibraryType,
164
+ meta: { voice?: string; voiceId?: string },
165
+ ): string | undefined {
166
+ if (type === 'voice') {
167
+ // Check female first: 'female' contains the substring 'male'.
168
+ const probe = `${meta.voiceId ?? ''} ${meta.voice ?? ''}`.toLowerCase()
169
+ if (/female|女/.test(probe)) return 'female'
170
+ if (/male|男/.test(probe)) return 'male'
171
+ return 'custom'
172
+ }
173
+ if (type === 'tts') return sanitizeSegment(meta.voice ?? meta.voiceId ?? 'default')
174
+ return undefined
175
+ }
176
+
177
+ /** Default resource name from the prompt. */
178
+ export function defaultLibraryName(prompt: string): string {
179
+ const flat = prompt.replace(/\s+/g, ' ').trim()
180
+ return flat === '' ? '未命名音频' : (flat.length > LIBRARY_NAME_MAX ? `${flat.slice(0, LIBRARY_NAME_MAX)}…` : flat)
181
+ }
182
+
183
+ async function readLibraryIndex(): Promise<LibraryEntry[]> {
184
+ try {
185
+ const text = await readFile(LIBRARY_INDEX_FILE, 'utf8')
186
+ const parsed = JSON.parse(text) as unknown
187
+ if (!Array.isArray(parsed)) return []
188
+ return parsed.filter(isLibraryEntry)
189
+ } catch {
190
+ return []
191
+ }
192
+ }
193
+
194
+ function isLibraryEntry(value: unknown): value is LibraryEntry {
195
+ if (value === null || typeof value !== 'object') return false
196
+ const raw = value as Record<string, unknown>
197
+ return typeof raw.id === 'string'
198
+ && typeof raw.name === 'string'
199
+ && (raw.type === 'voice' || raw.type === 'music' || raw.type === 'sfx' || raw.type === 'tts')
200
+ && Array.isArray(raw.files)
201
+ && typeof raw.createdAt === 'number'
202
+ && typeof (raw as Record<string, unknown>).provenance === 'object'
203
+ }
204
+
205
+ async function writeLibraryIndex(entries: LibraryEntry[]): Promise<void> {
206
+ await mkdir(LIBRARY_DATA_DIR, { recursive: true })
207
+ await writeFile(LIBRARY_INDEX_FILE, JSON.stringify(entries, null, 2))
208
+ }
209
+
210
+ /** Same-origin URL for a library-relative file path. */
211
+ export function libraryUrlOf(rel: string): string {
212
+ return `${LIBRARY_API.audio}/${rel.split('/').map(segment => encodeURIComponent(segment)).join('/')}`
213
+ }
214
+
215
+ /** Merge-library-entry: copy one audio/ file into library/<type>/<category>/. */
216
+ async function copyIntoLibrary(input: LibraryAudioInput, typeDir: string, category: string): Promise<LibraryFileRef> {
217
+ const stored = await readAudioFile(input.file)
218
+ if (stored === undefined) {
219
+ throw new Error(`音频文件不存在:${input.file}(请重新生成后再入库)`)
220
+ }
221
+ const ext = path.extname(input.file).replace('.', '') || (stored.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'bin')
222
+ const rel = `${typeDir}/${category}/${input.id}.${ext}`
223
+ const target = path.join(LIBRARY_DATA_DIR, ...rel.split('/'))
224
+ await mkdir(path.dirname(target), { recursive: true })
225
+ await writeFile(target, stored.data)
226
+ return {
227
+ url: libraryUrlOf(rel),
228
+ rel,
229
+ mime: stored.mime,
230
+ bytes: stored.bytes,
231
+ ...(input.duration === undefined ? {} : { duration: input.duration }),
232
+ ...(input.voiceId === undefined ? {} : { voiceId: input.voiceId }),
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Save one curated library entry: copies the referenced audio files into
238
+ * library/<type>/<category>/ (audio/ files stay untouched) and appends the
239
+ * entry to the index.
240
+ */
241
+ export async function saveToLibrary(input: {
242
+ audioFiles: LibraryAudioInput[]
243
+ type: LibraryType
244
+ category?: string
245
+ name?: string
246
+ tags?: string[]
247
+ note?: string
248
+ provenance: LibraryProvenance
249
+ }): Promise<LibraryEntry> {
250
+ if (input.audioFiles.length === 0) throw new Error('没有可入库的音频文件')
251
+ const typeDir = LIBRARY_TYPE_DIRS[input.type]
252
+ const category = input.category !== undefined && input.category.trim() !== ''
253
+ ? sanitizeSegment(input.category.trim())
254
+ : defaultLibraryCategory(input.type, {
255
+ voice: input.provenance.voice,
256
+ voiceId: input.provenance.voiceId ?? input.audioFiles.find(file => file.voiceId !== undefined)?.voiceId,
257
+ }) ?? 'default'
258
+ const files = await Promise.all(input.audioFiles.map(file => copyIntoLibrary(file, typeDir, category)))
259
+ const rawName = (input.name ?? '').trim()
260
+ const entry: LibraryEntry = {
261
+ id: randomUUID(),
262
+ createdAt: Date.now(),
263
+ type: input.type,
264
+ category: category === 'default' && input.type !== 'voice' && input.type !== 'tts' ? undefined : category,
265
+ name: rawName === '' ? defaultLibraryName(input.provenance.prompt) : rawName,
266
+ tags: Array.isArray(input.tags) ? [...new Set(input.tags.map(tag => tag.trim()).filter(tag => tag !== ''))].slice(0, 20) : [],
267
+ ...(input.note !== undefined && input.note.trim() !== '' ? { note: input.note.trim() } : {}),
268
+ files,
269
+ provenance: input.provenance,
270
+ }
271
+ const entries = await readLibraryIndex()
272
+ entries.unshift(entry)
273
+ await writeLibraryIndex(entries)
274
+ return entry
275
+ }
276
+
277
+ /** Read library entries (newest first). */
278
+ export async function listLibrary(): Promise<LibraryEntry[]> {
279
+ const entries = await readLibraryIndex()
280
+ return [...entries].sort((a, b) => b.createdAt - a.createdAt)
281
+ }
282
+
283
+ /** Move one library-relative file to a new rel path (same volume rename, else copy). */
284
+ async function moveLibraryFile(fromRel: string, toRel: string): Promise<void> {
285
+ const from = path.join(LIBRARY_DATA_DIR, ...fromRel.split('/'))
286
+ const to = path.join(LIBRARY_DATA_DIR, ...toRel.split('/'))
287
+ await mkdir(path.dirname(to), { recursive: true })
288
+ try {
289
+ await rename(from, to)
290
+ } catch {
291
+ const data = await readFile(from)
292
+ await writeFile(to, data)
293
+ await unlink(from)
294
+ }
295
+ }
296
+
297
+ /** Patch name/tags/note/type/category; moving type/category relocates files. */
298
+ export async function updateLibraryEntry(id: string, patch: {
299
+ name?: string
300
+ tags?: string[]
301
+ note?: string
302
+ category?: string
303
+ type?: LibraryType
304
+ }): Promise<LibraryEntry | undefined> {
305
+ const entries = await readLibraryIndex()
306
+ const index = entries.findIndex(entry => entry.id === id)
307
+ if (index < 0) return undefined
308
+ const entry = { ...entries[index]!, files: [...entries[index]!.files] }
309
+ if (patch.type !== undefined && LIBRARY_TYPES_VALID.includes(patch.type)) entry.type = patch.type
310
+ if (patch.name !== undefined) entry.name = patch.name.trim() === '' ? defaultLibraryName(entry.provenance.prompt) : patch.name.trim()
311
+ if (patch.tags !== undefined) entry.tags = [...new Set(patch.tags.map(tag => tag.trim()).filter(tag => tag !== ''))].slice(0, 20)
312
+ if (patch.note !== undefined) entry.note = patch.note.trim() === '' ? undefined : patch.note.trim()
313
+ if (patch.category !== undefined && patch.category.trim() !== '') {
314
+ const next = sanitizeSegment(patch.category.trim())
315
+ if (entry.type === 'voice' || entry.type === 'tts') entry.category = next
316
+ }
317
+ const oldCat = entries[index]!.category ?? 'default'
318
+ const newCat = entry.category ?? 'default'
319
+ if (entries[index]!.type !== entry.type || oldCat !== newCat) {
320
+ const moved: LibraryFileRef[] = []
321
+ for (const file of entry.files) {
322
+ const fileName = file.rel.split('/').pop() ?? ''
323
+ const fromRel = `${LIBRARY_TYPE_DIRS[entries[index]!.type]}/${oldCat}/${fileName}`
324
+ const toRel = `${LIBRARY_TYPE_DIRS[entry.type]}/${newCat}/${fileName}`
325
+ if (fromRel !== toRel) await moveLibraryFile(fromRel, toRel)
326
+ moved.push({ ...file, rel: toRel, url: libraryUrlOf(toRel) })
327
+ }
328
+ entry.files = moved
329
+ }
330
+ entries[index] = entry
331
+ await writeLibraryIndex(entries)
332
+ return entry
333
+ }
334
+
335
+ /** Remove entries and their audio files; best-effort prune empty dirs. */
336
+ export async function removeLibraryEntries(ids: string[]): Promise<LibraryEntry[]> {
337
+ const entries = await readLibraryIndex()
338
+ const doomed = new Set(ids)
339
+ const kept = entries.filter(entry => !doomed.has(entry.id))
340
+ for (const entry of entries) {
341
+ if (!doomed.has(entry.id)) continue
342
+ for (const file of entry.files) {
343
+ try {
344
+ await unlink(path.join(LIBRARY_DATA_DIR, ...file.rel.split('/')))
345
+ } catch {
346
+ // best-effort
347
+ }
348
+ }
349
+ }
350
+ // prune now-empty leaf dirs (best effort, one level under type dirs)
351
+ for (const entry of entries) {
352
+ if (!doomed.has(entry.id)) continue
353
+ try {
354
+ await rmdir(path.dirname(path.join(LIBRARY_DATA_DIR, ...entry.files[0]!.rel.split('/'))), { recursive: false })
355
+ } catch {
356
+ // dir not empty or already gone
357
+ }
358
+ }
359
+ await writeLibraryIndex(kept)
360
+ return kept
361
+ }
362
+
363
+ /** Read one library file by its rel path (whitelisted, traversal-safe). */
364
+ export async function readLibraryFile(rel: string): Promise<{ data: Buffer; mime: string; bytes: number } | undefined> {
365
+ const segments = rel.split('/').filter(segment => segment !== '')
366
+ if (segments.length < 2 || segments.length > 3) return undefined
367
+ const [typeDir, category, fileName] = segments
368
+ if (typeDir === undefined || !Object.values(LIBRARY_TYPE_DIRS).includes(typeDir)) return undefined
369
+ if (category === undefined || sanitizeSegment(category) !== category || category.length > 60) return undefined
370
+ if (fileName === undefined || !/^[0-9a-f-]{36}\.[a-z0-9]{2,5}$/i.test(fileName)) return undefined
371
+ const full = path.join(LIBRARY_DATA_DIR, typeDir, category, fileName)
372
+ if (!full.startsWith(path.join(LIBRARY_DATA_DIR, typeDir, category) + path.sep)) return undefined
373
+ try {
374
+ const data = await readFile(full)
375
+ return { data, mime: mimeFromFile(fileName), bytes: data.byteLength }
376
+ } catch {
377
+ return undefined
378
+ }
379
+ }
380
+
381
+ const LIBRARY_TYPES_VALID = ['voice', 'music', 'sfx', 'tts'] as const