dsh-audiogen 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Custom audio player: play/pause, seekable progress and time readout.
3
+ * Replaces the native `<audio controls>` look with a themed control so the
4
+ * panel matches the DSH design tokens in light and dark modes.
5
+ */
6
+
7
+ import { useCallback, useEffect, useRef, useState } from 'react'
8
+ import { MuteIcon, PauseIcon, PlayIcon, VolumeIcon } from './icons.tsx'
9
+ import css from './audio-panel.module.css'
10
+
11
+ function format(seconds: number): string {
12
+ if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
13
+ const whole = Math.floor(seconds)
14
+ const minutes = Math.floor(whole / 60)
15
+ const rest = whole % 60
16
+ return `${minutes}:${rest < 10 ? `0${rest}` : String(rest)}`
17
+ }
18
+
19
+ export function AudioPlayer(props: {
20
+ src: string
21
+ compact?: boolean
22
+ /** Optional key to force a fresh element when the source changes identity. */
23
+ itemKey?: string
24
+ }): React.JSX.Element {
25
+ const ref = useRef<HTMLAudioElement | null>(null)
26
+ const [playing, setPlaying] = useState(false)
27
+ const [muted, setMuted] = useState(false)
28
+ const [current, setCurrent] = useState(0)
29
+ const [duration, setDuration] = useState(0)
30
+
31
+ useEffect(() => {
32
+ // Reset when the source changes (the media element re-loads).
33
+ setPlaying(false)
34
+ setCurrent(0)
35
+ setDuration(0)
36
+ }, [props.src])
37
+
38
+ const toggle = useCallback((): void => {
39
+ const el = ref.current
40
+ if (el === null) return
41
+ if (el.paused) void el.play().catch(() => { /* autoplay/interrupt best-effort */ })
42
+ else el.pause()
43
+ }, [])
44
+
45
+ const seek = useCallback((event: React.MouseEvent<HTMLDivElement>): void => {
46
+ const el = ref.current
47
+ if (el === null || duration <= 0) return
48
+ const rect = event.currentTarget.getBoundingClientRect()
49
+ const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
50
+ el.currentTime = ratio * duration
51
+ }, [duration])
52
+
53
+ const toggleMute = useCallback((): void => {
54
+ const el = ref.current
55
+ if (el === null) return
56
+ el.muted = !el.muted
57
+ setMuted(el.muted)
58
+ }, [])
59
+
60
+ const percent = duration > 0 ? Math.min(100, (current / duration) * 100) : 0
61
+
62
+ return (
63
+ <div className={props.compact === true ? css.playerCompact : css.player} data-playing={playing ? 'true' : 'false'}>
64
+ <audio
65
+ ref={ref}
66
+ src={props.src}
67
+ preload="metadata"
68
+ onPlay={() => setPlaying(true)}
69
+ onPause={() => setPlaying(false)}
70
+ onEnded={() => setPlaying(false)}
71
+ onTimeUpdate={event => setCurrent(event.currentTarget.currentTime)}
72
+ onLoadedMetadata={event => setDuration(event.currentTarget.duration)}
73
+ />
74
+ <button type="button" className={css.playButton} aria-label={playing ? '暂停' : '播放'} onClick={toggle}>
75
+ {playing ? <PauseIcon /> : <PlayIcon />}
76
+ </button>
77
+ <div className={css.track} role="slider" aria-label="播放进度" aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={Math.round(current)} onClick={seek}>
78
+ <div className={css.trackFill} style={{ width: `${percent}%` }} />
79
+ <div className={css.trackKnob} style={{ left: `${percent}%` }} />
80
+ </div>
81
+ <span className={css.time}>{format(current)}{duration > 0 ? ` / ${format(duration)}` : ''}</span>
82
+ <button type="button" className={css.muteButton} aria-label={muted ? '取消静音' : '静音'} onClick={toggleMute}>
83
+ {muted ? <MuteIcon /> : <VolumeIcon />}
84
+ </button>
85
+ </div>
86
+ )
87
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tiny inline SVG icon set for the AI 音频 panel (no icon dependency).
3
+ * Each icon is a 1em × 1em stroke/fill glyph that inherits currentColor.
4
+ */
5
+
6
+ import type { JSX, ReactNode } from 'react'
7
+
8
+ function Svg(props: { children: ReactNode; viewBox?: string }): JSX.Element {
9
+ return (
10
+ <svg
11
+ width="1em"
12
+ height="1em"
13
+ viewBox={props.viewBox ?? '0 0 16 16'}
14
+ fill="none"
15
+ stroke="currentColor"
16
+ strokeWidth="1.5"
17
+ strokeLinecap="round"
18
+ strokeLinejoin="round"
19
+ aria-hidden="true"
20
+ >
21
+ {props.children}
22
+ </svg>
23
+ )
24
+ }
25
+
26
+ export function PlayIcon(): JSX.Element {
27
+ return (
28
+ <Svg viewBox="0 0 16 16">
29
+ <path d="M5 3.2v9.6a.6.6 0 0 0 .9.5l7.5-4.8a.6.6 0 0 0 0-1L5.9 2.7a.6.6 0 0 0-.9.5Z" fill="currentColor" stroke="none" />
30
+ </Svg>
31
+ )
32
+ }
33
+
34
+ export function PauseIcon(): JSX.Element {
35
+ return (
36
+ <Svg viewBox="0 0 16 16">
37
+ <rect x="4.2" y="2.8" width="2.6" height="10.4" rx="0.9" fill="currentColor" stroke="none" />
38
+ <rect x="9.2" y="2.8" width="2.6" height="10.4" rx="0.9" fill="currentColor" stroke="none" />
39
+ </Svg>
40
+ )
41
+ }
42
+
43
+ export function StarIcon(props: { filled?: boolean }): JSX.Element {
44
+ return (
45
+ <Svg viewBox="0 0 16 16">
46
+ <path
47
+ d="M8 1.9l1.8 3.7 4.1.6-3 2.9.7 4.1L8 11.2l-3.6 1.9.7-4.1-3-2.9 4.1-.6L8 1.9Z"
48
+ fill={props.filled === true ? 'currentColor' : 'none'}
49
+ />
50
+ </Svg>
51
+ )
52
+ }
53
+
54
+ export function DownloadIcon(): JSX.Element {
55
+ return (
56
+ <Svg viewBox="0 0 16 16">
57
+ <path d="M8 2.5v7M8 9.5l-2.6-2.6M8 9.5l2.6-2.6" />
58
+ <path d="M2.8 10.8v1.7a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1v-1.7" />
59
+ </Svg>
60
+ )
61
+ }
62
+
63
+ export function TrashIcon(): JSX.Element {
64
+ return (
65
+ <Svg viewBox="0 0 16 16">
66
+ <path d="M3 4.4h10M6.2 2.9h3.6M5.2 4.4l.5 8a1 1 0 0 0 1 .9h2.6a1 1 0 0 0 1-.9l.5-8" />
67
+ </Svg>
68
+ )
69
+ }
70
+
71
+ export function SearchIcon(): JSX.Element {
72
+ return (
73
+ <Svg viewBox="0 0 16 16">
74
+ <circle cx="7" cy="7" r="4.2" />
75
+ <path d="m10.2 10.2 3 3" />
76
+ </Svg>
77
+ )
78
+ }
79
+
80
+ export function CopyIcon(): JSX.Element {
81
+ return (
82
+ <Svg viewBox="0 0 16 16">
83
+ <rect x="5.4" y="5.4" width="7.4" height="7.4" rx="1.2" />
84
+ <path d="M10.6 5.4V4a1.2 1.2 0 0 0-1.2-1.2H4A1.2 1.2 0 0 0 2.8 4v5.4A1.2 1.2 0 0 0 4 10.6h1.4" />
85
+ </Svg>
86
+ )
87
+ }
88
+
89
+ export function CheckIcon(): JSX.Element {
90
+ return (
91
+ <Svg viewBox="0 0 16 16">
92
+ <path d="m3.2 8.4 3.1 3.1L12.8 4.9" />
93
+ </Svg>
94
+ )
95
+ }
96
+
97
+ export function CloseIcon(): JSX.Element {
98
+ return (
99
+ <Svg viewBox="0 0 16 16">
100
+ <path d="m4 4 8 8M12 4l-8 8" />
101
+ </Svg>
102
+ )
103
+ }
104
+
105
+ export function VolumeIcon(): JSX.Element {
106
+ return (
107
+ <Svg viewBox="0 0 16 16">
108
+ <path d="M2.8 6.2v3.6h2.4l3 2.6V3.6l-3 2.6H2.8Z" fill="currentColor" stroke="none" />
109
+ <path d="M10.4 5.6a3.4 3.4 0 0 1 0 4.8M12 4.2a5.6 5.6 0 0 1 0 7.6" />
110
+ </Svg>
111
+ )
112
+ }
113
+
114
+ export function MuteIcon(): JSX.Element {
115
+ return (
116
+ <Svg viewBox="0 0 16 16">
117
+ <path d="M2.8 6.2v3.6h2.4l3 2.6V3.6l-3 2.6H2.8Z" fill="currentColor" stroke="none" />
118
+ <path d="m10.4 6.2 3.2 3.6M13.6 6.2l-3.2 3.6" />
119
+ </Svg>
120
+ )
121
+ }
122
+
123
+ export function MicIcon(): JSX.Element {
124
+ return (
125
+ <Svg viewBox="0 0 16 16">
126
+ <rect x="5.8" y="2" width="4.4" height="7.2" rx="2.2" />
127
+ <path d="M3.2 8a4.8 4.8 0 0 0 9.6 0M8 12.8V14" />
128
+ </Svg>
129
+ )
130
+ }
131
+
132
+ export function MusicNoteIcon(): JSX.Element {
133
+ return (
134
+ <Svg viewBox="0 0 16 16">
135
+ <path d="M6.2 12.6V4l6-1.4v8.6" />
136
+ <circle cx="4.7" cy="12.6" r="1.7" />
137
+ <circle cx="10.7" cy="11.2" r="1.7" />
138
+ </Svg>
139
+ )
140
+ }
141
+
142
+ export function WaveIcon(): JSX.Element {
143
+ return (
144
+ <Svg viewBox="0 0 16 16">
145
+ <path d="M2 6h1M4.5 3.5v9M7.2 2v12M9.9 4v8M12.6 5.5v5M14.5 6.5v3" />
146
+ </Svg>
147
+ )
148
+ }
149
+
150
+ export function GridIcon(): JSX.Element {
151
+ return (
152
+ <Svg viewBox="0 0 16 16">
153
+ <rect x="2.5" y="2.5" width="4.6" height="4.6" rx="1" />
154
+ <rect x="8.9" y="2.5" width="4.6" height="4.6" rx="1" />
155
+ <rect x="2.5" y="8.9" width="4.6" height="4.6" rx="1" />
156
+ <rect x="8.9" y="8.9" width="4.6" height="4.6" rx="1" />
157
+ </Svg>
158
+ )
159
+ }
160
+
161
+ export function ListIcon(): JSX.Element {
162
+ return (
163
+ <Svg viewBox="0 0 16 16">
164
+ <path d="M5.4 4.2h8.2M5.4 8h8.2M5.4 11.8h8.2" />
165
+ <circle cx="2.8" cy="4.2" r="0.8" fill="currentColor" stroke="none" />
166
+ <circle cx="2.8" cy="8" r="0.8" fill="currentColor" stroke="none" />
167
+ <circle cx="2.8" cy="11.8" r="0.8" fill="currentColor" stroke="none" />
168
+ </Svg>
169
+ )
170
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * «保存到资源库» dialog: pick type/category, set name/tags/note, then save.
3
+ * Built from either freshly generated audio or a history entry.
4
+ */
5
+
6
+ import { useMemo, useState } from 'react'
7
+ import type { AudiogenApi } from './api.ts'
8
+ import type { AudioMode, GeneratedAudio, LibraryEntry, LibraryType } from '../protocol.ts'
9
+ import css from './audio-panel.module.css'
10
+
11
+ export interface SaveDialogContext {
12
+ mode: AudioMode
13
+ prompt: string
14
+ voice?: string
15
+ voiceId?: string
16
+ model?: string
17
+ channel?: string
18
+ channelId?: string
19
+ params?: Record<string, unknown>
20
+ }
21
+
22
+ export function typeOfMode(mode: AudioMode): LibraryType {
23
+ if (mode === 'voice_design') return 'voice'
24
+ return mode
25
+ }
26
+
27
+ export const LIBRARY_TYPE_LABELS: Record<LibraryType, string> = {
28
+ voice: '音色',
29
+ music: '音乐',
30
+ sfx: '音效',
31
+ tts: 'TTS 语音',
32
+ }
33
+
34
+ function parseTags(text: string): string[] {
35
+ return [...new Set(text.split(/[,,、\n]/).map(tag => tag.trim()).filter(tag => tag !== ''))].slice(0, 20)
36
+ }
37
+
38
+ function guessCategory(type: LibraryType, context: SaveDialogContext): string {
39
+ if (type === 'voice') {
40
+ const probe = `${context.voiceId ?? ''} ${context.voice ?? ''}`.toLowerCase()
41
+ if (/male|男/.test(probe)) return 'male'
42
+ if (/female|女/.test(probe)) return 'female'
43
+ return 'custom'
44
+ }
45
+ if (type === 'tts') return (context.voice ?? context.voiceId ?? 'default').replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]+/g, '_')
46
+ return ''
47
+ }
48
+
49
+ export function LibrarySaveDialog(props: {
50
+ api: AudiogenApi
51
+ files: GeneratedAudio[]
52
+ context: SaveDialogContext
53
+ onClose: () => void
54
+ onSaved: (entry: LibraryEntry) => void
55
+ }): React.JSX.Element {
56
+ const { api, files, context } = props
57
+ const [type, setType] = useState<LibraryType>(typeOfMode(context.mode))
58
+ const [category, setCategory] = useState(() => guessCategory(typeOfMode(context.mode), context))
59
+ const [name, setName] = useState('')
60
+ const [tags, setTags] = useState('')
61
+ const [note, setNote] = useState('')
62
+ const [saving, setSaving] = useState(false)
63
+ const [error, setError] = useState<string | null>(null)
64
+
65
+ const isVoice = type === 'voice'
66
+ const isTts = type === 'tts'
67
+
68
+ const defaultName = useMemo(() => {
69
+ const flat = context.prompt.replace(/\s+/g, ' ').trim()
70
+ return flat === '' ? '未命名音频' : (flat.length > 40 ? `${flat.slice(0, 40)}…` : flat)
71
+ }, [context.prompt])
72
+
73
+ const switchType = (next: LibraryType): void => {
74
+ setType(next)
75
+ setCategory(guessCategory(next, context))
76
+ }
77
+
78
+ const save = async (): Promise<void> => {
79
+ setSaving(true)
80
+ setError(null)
81
+ try {
82
+ const result = await api.librarySave({
83
+ audioFiles: files.map(file => ({
84
+ id: file.id,
85
+ file: file.file,
86
+ mime: file.mime,
87
+ ...(file.voiceId === undefined ? {} : { voiceId: file.voiceId }),
88
+ ...(file.duration === undefined ? {} : { duration: file.duration }),
89
+ })),
90
+ type,
91
+ ...(category.trim() !== '' && (isVoice || isTts) ? { category: category.trim() } : {}),
92
+ ...(name.trim() !== '' ? { name: name.trim() } : {}),
93
+ ...(parseTags(tags).length > 0 ? { tags: parseTags(tags) } : {}),
94
+ ...(note.trim() !== '' ? { note: note.trim() } : {}),
95
+ provenance: {
96
+ mode: context.mode,
97
+ prompt: context.prompt,
98
+ ...(context.channel === undefined ? {} : { channel: context.channel }),
99
+ ...(context.channelId === undefined ? {} : { channelId: context.channelId }),
100
+ ...(context.model === undefined ? {} : { model: context.model }),
101
+ ...(context.voice === undefined ? {} : { voice: context.voice }),
102
+ ...(context.voiceId === undefined ? {} : { voiceId: context.voiceId }),
103
+ ...(context.params === undefined ? {} : { params: context.params }),
104
+ },
105
+ })
106
+ if (!result.ok || result.entry === undefined) {
107
+ setError(result.message ?? '保存失败')
108
+ return
109
+ }
110
+ props.onSaved(result.entry)
111
+ } catch (err) {
112
+ setError(err instanceof Error ? err.message : String(err))
113
+ } finally {
114
+ setSaving(false)
115
+ }
116
+ }
117
+
118
+ return (
119
+ <div className={css.modalMask}>
120
+ <div className={css.modal} role="dialog" aria-modal="true" aria-label="保存到资源库">
121
+ <div className={css.modalHead}>
122
+ <strong>{files.length > 1 ? `保存 ${files.length} 段音频到资源库` : '保存到资源库'}</strong>
123
+ <button type="button" className={css.iconButton} aria-label="关闭" onClick={props.onClose}>×</button>
124
+ </div>
125
+ <div className={css.modalBody}>
126
+ <div className={css.formRow}>
127
+ <label className={css.label}>
128
+ <span>资源类型</span>
129
+ <select className={css.input} value={type} onChange={event => switchType(event.target.value as LibraryType)}>
130
+ {(['voice', 'music', 'sfx', 'tts'] as LibraryType[]).map(item => (
131
+ <option key={item} value={item}>{LIBRARY_TYPE_LABELS[item]}</option>
132
+ ))}
133
+ </select>
134
+ </label>
135
+ {isVoice || isTts ? (
136
+ <label className={css.label}>
137
+ <span>{isVoice ? '音色分级(男 / 女)' : '按音色归档(voice 键)'}</span>
138
+ {isVoice ? (
139
+ <select className={css.input} value={category} onChange={event => setCategory(event.target.value)}>
140
+ <option value="male">男声 male</option>
141
+ <option value="female">女声 female</option>
142
+ <option value="custom">未分级 custom</option>
143
+ </select>
144
+ ) : (
145
+ <input className={css.input} value={category} onChange={event => setCategory(event.target.value)} placeholder={context.voice ?? 'default'} />
146
+ )}
147
+ </label>
148
+ ) : null}
149
+ </div>
150
+ <label className={css.label}>
151
+ <span>名称(留空则使用提示词)</span>
152
+ <input className={css.input} value={name} onChange={event => setName(event.target.value)} placeholder={defaultName} />
153
+ </label>
154
+ <label className={css.label}>
155
+ <span>标签(逗号分隔,可多个)</span>
156
+ <input className={css.input} value={tags} onChange={event => setTags(event.target.value)} placeholder={'温暖, 男声, 复古…'} />
157
+ </label>
158
+ <label className={css.label}>
159
+ <span>备注(可选)</span>
160
+ <textarea className={css.textarea} rows={2} value={note} onChange={event => setNote(event.target.value)} placeholder="用途、风格备注…" />
161
+ </label>
162
+ {error !== null ? <p className={css.error}>{error}</p> : null}
163
+ </div>
164
+ <div className={css.modalFoot}>
165
+ <button type="button" className={css.secondaryButton} onClick={props.onClose}>取消</button>
166
+ <button type="button" className={css.primaryButton} disabled={saving || files.length === 0} onClick={() => void save()}>
167
+ {saving ? '保存中…' : '保存到资源库'}
168
+ </button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ )
173
+ }