dsh-audiogen 0.1.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,198 @@
1
+ .panel {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 12px;
5
+ height: 100%;
6
+ padding: 14px 16px;
7
+ overflow: hidden;
8
+ background: var(--dsw-alias-bg-base, #f7f7f8);
9
+ color: var(--dsw-alias-label-primary, #1f2328);
10
+ font-family: var(--dsw-font-family, system-ui, sans-serif);
11
+ }
12
+
13
+ .header {
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: space-between;
17
+ }
18
+
19
+ .title {
20
+ margin: 0;
21
+ font-size: 16px;
22
+ font-weight: 700;
23
+ }
24
+
25
+ .layout {
26
+ display: flex;
27
+ gap: 14px;
28
+ flex: 1;
29
+ min-height: 0;
30
+ }
31
+
32
+ .form {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 12px;
36
+ flex: none;
37
+ width: 300px;
38
+ min-width: 260px;
39
+ max-width: 340px;
40
+ overflow-y: auto;
41
+ }
42
+
43
+ .result {
44
+ flex: 1;
45
+ min-width: 0;
46
+ display: flex;
47
+ flex-direction: column;
48
+ border: 1px solid var(--dsw-alias-border-l1, #e5e7eb);
49
+ border-radius: 12px;
50
+ background: var(--dsw-alias-bg-layer-1, #fff);
51
+ padding: 14px;
52
+ overflow-y: auto;
53
+ }
54
+
55
+ .label {
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: 5px;
59
+ font-size: 12px;
60
+ font-weight: 600;
61
+ color: var(--dsw-alias-label-secondary, #6b7280);
62
+ }
63
+
64
+ .textarea,
65
+ .input,
66
+ .select {
67
+ width: 100%;
68
+ min-height: 36px;
69
+ padding: 7px 10px;
70
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
71
+ border-radius: 8px;
72
+ background: var(--dsw-alias-bg-layer-3, #fff);
73
+ color: var(--dsw-alias-label-primary, #1f2328);
74
+ font: inherit;
75
+ font-size: 13px;
76
+ outline: none;
77
+ }
78
+
79
+ .textarea {
80
+ min-height: 110px;
81
+ resize: vertical;
82
+ }
83
+
84
+ .modeRow {
85
+ display: flex;
86
+ gap: 6px;
87
+ }
88
+
89
+ .modeButton {
90
+ flex: 1;
91
+ padding: 7px 8px;
92
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
93
+ border-radius: 8px;
94
+ background: transparent;
95
+ font: inherit;
96
+ font-size: 12px;
97
+ cursor: pointer;
98
+ }
99
+
100
+ .modeButton[data-active='true'] {
101
+ border-color: var(--dsw-alias-brand-primary, #2563eb);
102
+ color: var(--dsw-alias-brand-primary, #2563eb);
103
+ font-weight: 600;
104
+ }
105
+
106
+ .generate {
107
+ padding: 9px 14px;
108
+ border: 0;
109
+ border-radius: 10px;
110
+ background: var(--dsw-alias-label-primary, #1f2328);
111
+ color: var(--dsw-alias-bg-layer-3, #fff);
112
+ font: inherit;
113
+ font-size: 13px;
114
+ cursor: pointer;
115
+ }
116
+
117
+ .generate:disabled {
118
+ opacity: 0.5;
119
+ cursor: default;
120
+ }
121
+
122
+ .empty,
123
+ .error,
124
+ .hint {
125
+ font-size: 13px;
126
+ color: var(--dsw-alias-label-secondary, #6b7280);
127
+ }
128
+
129
+ .error {
130
+ color: #b91c1c;
131
+ }
132
+
133
+ .audioList {
134
+ display: grid;
135
+ gap: 10px;
136
+ margin-top: 8px;
137
+ }
138
+
139
+ .audioCard {
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: 6px;
143
+ padding: 10px;
144
+ border: 1px solid var(--dsw-alias-border-l2, #e5e7eb);
145
+ border-radius: 10px;
146
+ }
147
+
148
+ .audio {
149
+ width: 100%;
150
+ height: 36px;
151
+ }
152
+
153
+ .download {
154
+ font-size: 12px;
155
+ color: #2563eb;
156
+ text-decoration: none;
157
+ }
158
+
159
+ .history {
160
+ flex: none;
161
+ width: 260px;
162
+ overflow-y: auto;
163
+ border-left: 1px solid var(--dsw-alias-border-l1, #e5e7eb);
164
+ padding-left: 12px;
165
+ }
166
+
167
+ .historyTitle {
168
+ font-size: 13px;
169
+ font-weight: 700;
170
+ }
171
+
172
+ .historyEmpty {
173
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
174
+ font-size: 12px;
175
+ }
176
+
177
+ .historyItem {
178
+ padding: 8px 0;
179
+ border-bottom: 1px solid var(--dsw-alias-border-l1, #e5e7eb);
180
+ }
181
+
182
+ .historyPrompt {
183
+ font-size: 12px;
184
+ overflow: hidden;
185
+ text-overflow: ellipsis;
186
+ white-space: nowrap;
187
+ }
188
+
189
+ .historyMeta {
190
+ font-size: 11px;
191
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
192
+ }
193
+
194
+ .historyAudio {
195
+ width: 100%;
196
+ height: 30px;
197
+ margin-top: 4px;
198
+ }
@@ -0,0 +1,69 @@
1
+ .root {
2
+ display: block;
3
+ min-width: 0;
4
+ border: 1px solid var(--dsw-color-border, #e5e7eb);
5
+ border-radius: 10px;
6
+ background: var(--dsw-color-surface, #fff);
7
+ padding: 10px 12px;
8
+ }
9
+
10
+ .header {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 8px;
14
+ font-weight: 600;
15
+ }
16
+
17
+ .icon {
18
+ color: var(--dsw-alias-label-secondary, #6b7280);
19
+ }
20
+
21
+ .status {
22
+ margin-left: auto;
23
+ font-size: 12px;
24
+ font-weight: 400;
25
+ color: var(--dsw-alias-label-caption, #9ca3af);
26
+ }
27
+
28
+ .message {
29
+ margin: 6px 0 0;
30
+ font-size: 13px;
31
+ color: var(--dsw-alias-label-secondary, #6b7280);
32
+ white-space: pre-wrap;
33
+ }
34
+
35
+ .error {
36
+ margin: 6px 0 0;
37
+ color: #b91c1c;
38
+ }
39
+
40
+ .audios {
41
+ display: grid;
42
+ gap: 8px;
43
+ margin-top: 8px;
44
+ }
45
+
46
+ .audioRow {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 8px;
50
+ }
51
+
52
+ .audio {
53
+ flex: 1;
54
+ min-width: 0;
55
+ width: 100%;
56
+ height: 36px;
57
+ }
58
+
59
+ .download {
60
+ font-size: 12px;
61
+ color: #2563eb;
62
+ text-decoration: none;
63
+ white-space: nowrap;
64
+ }
65
+
66
+ .empty {
67
+ color: var(--dsw-alias-label-caption, #9ca3af);
68
+ font-size: 13px;
69
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Inline renderer for audio-generation tool results.
3
+ *
4
+ * The model-facing tool result is a JSON text envelope with same-origin audio
5
+ * URLs; this view turns it into a small audio-player card.
6
+ */
7
+
8
+ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
9
+ import { useEffect, useMemo, useState } from 'react'
10
+ import css from './audio-toolview.module.css'
11
+
12
+ export interface AudioToolViewOwnerProps {
13
+ callId: string
14
+ toolName: string
15
+ block: unknown
16
+ cwd?: string
17
+ home?: string
18
+ openFile: (path: string) => void
19
+ inspect?: () => void
20
+ }
21
+
22
+ interface AudioFileRef {
23
+ id?: string
24
+ url: string
25
+ mime: string
26
+ bytes: number
27
+ }
28
+
29
+ interface AudioToolViewProps extends AudioToolViewOwnerProps {
30
+ sessionId: SessionId
31
+ }
32
+
33
+ function isSettled(block: unknown): block is { content?: Array<{ type: string; text?: string }>; isError?: boolean } {
34
+ return typeof block === 'object' && block !== null && 'content' in block
35
+ }
36
+
37
+ function textOf(block: unknown): string {
38
+ if (!isSettled(block)) return ''
39
+ return (block.content ?? [])
40
+ .filter(content => content.type === 'text' && typeof content.text === 'string')
41
+ .map(content => content.text as string)
42
+ .join('\n')
43
+ }
44
+
45
+ function parseResult(block: unknown): { status: string; message: string; audio: AudioFileRef[]; error?: string } {
46
+ const text = textOf(block)
47
+ if (text === '') return { status: 'running', message: '正在生成音频…', audio: [] }
48
+ try {
49
+ const parsed = JSON.parse(text) as { status?: string; message?: string; audio?: AudioFileRef[]; error?: string }
50
+ return {
51
+ status: parsed.status ?? 'completed',
52
+ message: parsed.message ?? '',
53
+ audio: Array.isArray(parsed.audio) ? parsed.audio.filter((item): item is AudioFileRef => {
54
+ return typeof item === 'object' && item !== null && typeof (item as AudioFileRef).url === 'string'
55
+ }) : [],
56
+ ...(typeof parsed.error === 'string' ? { error: parsed.error } : {}),
57
+ }
58
+ } catch {
59
+ return { status: 'completed', message: text, audio: [] }
60
+ }
61
+ }
62
+
63
+ function statusLabel(status: string): string {
64
+ if (status === 'running' || status === 'queued') return '生成中'
65
+ if (status === 'failed') return '生成失败'
66
+ if (status === 'cancelled') return '已取消'
67
+ return '音频结果'
68
+ }
69
+
70
+ function mimeExt(mime: string): string {
71
+ if (mime.includes('wav')) return 'wav'
72
+ if (mime.includes('flac')) return 'flac'
73
+ if (mime.includes('ogg')) return 'ogg'
74
+ if (mime.includes('mp4') || mime.includes('m4a')) return 'm4a'
75
+ return 'mp3'
76
+ }
77
+
78
+ export function registerAudioToolviews(ctx: ClientContext): void {
79
+ const AudioToolView = (props: AudioToolViewProps): React.JSX.Element => {
80
+ const result = useMemo(() => parseResult(props.block), [props.block])
81
+ const [active, setActive] = useState<string | null>(null)
82
+ useEffect(() => { setActive(null) }, [props.callId])
83
+ const title = props.toolName === 'generate_audio' ? '生成音频' : props.toolName
84
+ return (
85
+ <section className={css.root} data-state={result.status} data-tool={props.toolName}>
86
+ <header className={css.header}>
87
+ <span className={css.icon} aria-hidden="true">♫</span>
88
+ <strong>{title}</strong>
89
+ <span className={css.status}>{statusLabel(result.status)}</span>
90
+ </header>
91
+ {result.message !== '' && <p className={css.message}>{result.message}</p>}
92
+ {result.error !== undefined && <p className={css.error}>{result.error}</p>}
93
+ {result.audio.length > 0 && (
94
+ <div className={css.audios}>
95
+ {result.audio.map((audio, index) => (
96
+ <div className={css.audioRow} key={audio.id ?? audio.url}>
97
+ <audio
98
+ className={css.audio}
99
+ controls
100
+ preload="metadata"
101
+ src={audio.url}
102
+ onPlay={() => setActive(audio.url)}
103
+ />
104
+ <a className={css.download} href={audio.url} download={`generated-${index + 1}.${mimeExt(audio.mime)}`}>
105
+ 下载
106
+ </a>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ )}
111
+ {result.audio.length === 0 && result.status !== 'running' && <p className={css.empty}>未返回可播放的音频。</p>}
112
+ </section>
113
+ )
114
+ }
115
+
116
+ ctx.slots.inject('tool.call.toolview', function* () {
117
+ yield ctx.slots.register({ name: 'tool.call.toolview', key: 'generate_audio' }, AudioToolView)
118
+ })
119
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Staged form model for the channel list of the settings card. Mirrors the
3
+ * CardForm staging pattern (dirty → one save) but for a structured value, so
4
+ * the card can edit N channels, per-channel keys, and the default-channel
5
+ * flag, then persist everything in one revision-fenced mutate call.
6
+ *
7
+ * Storage rules (dictated by dsh-settings semantics):
8
+ * - the whole `channels` array is written wholesale via `path: ['channels']`;
9
+ * - every channel's API key lives at `channelSecrets.<channelId>` (a secret
10
+ * dict), written per-key so untouched keys are never clobbered by a save
11
+ * the reader could not see (keys are redacted out of the wire view);
12
+ * - path ops never navigate *inside* the channels array.
13
+ */
14
+
15
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
16
+ import type { ChannelConfig, ModelMapping } from '../protocol.ts'
17
+ import type { AudiogenScope, SettingsOp } from './settings-scope.ts'
18
+
19
+ /** One channel as the editor stages it (secrets never travel here). */
20
+ export interface ChannelDraft {
21
+ id: string
22
+ preset: string
23
+ name: string
24
+ apiUrl: string
25
+ models: ModelMapping[]
26
+ }
27
+
28
+ /** One staged key edit for a channel. */
29
+ export type KeyEdit = { kind: 'set'; value: string } | { kind: 'clear' }
30
+
31
+ /** The state the card renders. */
32
+ export interface ChannelsFormState {
33
+ /** Staged channel list (the scope value when nothing is staged). */
34
+ channels: ChannelDraft[]
35
+ /** Which channels currently hold a stored secret (staged edits included). */
36
+ keySet: Record<string, boolean>
37
+ /** The effective default channel id. */
38
+ defaultChannelId: string
39
+ /** Whether a save would write anything. */
40
+ dirty: boolean
41
+ /** Whether the document accepts writes. */
42
+ writable: boolean
43
+ /** Whether a save is crossing the wire. */
44
+ saving: boolean
45
+ /** Whether the last save failed (cleared by the next edit or save). */
46
+ failed: boolean
47
+ }
48
+
49
+ /** The actions the card's slot entry injects. */
50
+ export interface ChannelsFormActions {
51
+ /** Replace the whole channel list (add/edit/remove go through here). */
52
+ setChannels: (channels: ChannelDraft[]) => void
53
+ /** Stage a key for one channel ('' clears; undefined = no staged change). */
54
+ setChannelKey: (id: string, value: string | undefined) => void
55
+ /** Stage the default-channel flag. */
56
+ setDefaultChannel: (id: string) => void
57
+ /** Write every staged edit, then re-seed from what the Host accepted. */
58
+ commit: () => Promise<void>
59
+ /** Drop every staged edit. */
60
+ discard: () => void
61
+ }
62
+
63
+ /** Deep equality over JSON-compatible data (the change predicate). */
64
+ function deepEqualJson(a: unknown, b: unknown): boolean {
65
+ if (a === b) return true
66
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
67
+ if (Array.isArray(a) || Array.isArray(b)) {
68
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
69
+ return a.every((entry, index) => deepEqualJson(entry, b[index]))
70
+ }
71
+ const left = a as Record<string, unknown>
72
+ const right = b as Record<string, unknown>
73
+ const keys = Object.keys(left)
74
+ if (keys.length !== Object.keys(right).length) return false
75
+ return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
76
+ }
77
+
78
+ /** Trim and normalize a draft channel (models never carry empty aliases). */
79
+ function stripChannel(channel: ChannelDraft): ChannelDraft {
80
+ const models = channel.models
81
+ .map(model => ({ alias: model.alias.trim(), id: model.id.trim() === '' ? model.alias.trim() : model.id.trim() }))
82
+ .filter(model => model.alias !== '')
83
+ return {
84
+ id: channel.id,
85
+ preset: channel.preset,
86
+ name: channel.name.trim(),
87
+ apiUrl: channel.apiUrl.trim(),
88
+ models: [...new Map(models.map(model => [model.alias, model])).values()],
89
+ }
90
+ }
91
+
92
+ export class ChannelsForm {
93
+ private stagedChannels: ChannelDraft[] | null = null
94
+ private readonly stagedKeys = new Map<string, KeyEdit>()
95
+ private stagedDefault: string | null = null
96
+ private readonly listeners = new Set<() => void>()
97
+ private saving = false
98
+ private failed = false
99
+
100
+ constructor(private readonly scope: AudiogenScope) {
101
+ scope.subscribe(() => { this.publish() })
102
+ scope.subscribeSecretSets(() => { this.publish() })
103
+ }
104
+
105
+ /** Publish a projection of this form, rebuilt on every scope or draft change. */
106
+ bind<S>(project: () => S): SnapshotStore<S> {
107
+ const store = createSnapshotStore(project())
108
+ this.listeners.add(() => { store.set(project()) })
109
+ return store
110
+ }
111
+
112
+ /** Subscribe to staged and persisted channel changes. */
113
+ subscribe(listener: () => void): () => void {
114
+ this.listeners.add(listener)
115
+ return () => { this.listeners.delete(listener) }
116
+ }
117
+
118
+ /** The staged channel list, or the scope value when nothing is staged. */
119
+ private channelsValue(): ChannelDraft[] {
120
+ const view = this.scope.getSnapshot().value as { channels?: ChannelConfig[] } | undefined
121
+ return this.stagedChannels ?? (Array.isArray(view?.channels) ? view.channels.map(toDraft) : [])
122
+ }
123
+
124
+ /** Whether a channel currently holds a stored or staged secret. */
125
+ private keyHeld(id: string): boolean {
126
+ const edit = this.stagedKeys.get(id)
127
+ if (edit !== undefined) return edit.kind === 'set' && edit.value !== ''
128
+ return this.scope.getSecretSetSnapshot(`channelSecrets.${id}`)
129
+ }
130
+
131
+ private defaultValue(): string {
132
+ if (this.stagedDefault !== null) return this.stagedDefault
133
+ const view = this.scope.getSnapshot().value as { defaultChannelId?: string } | undefined
134
+ const channels = this.channelsValue()
135
+ if (view?.defaultChannelId !== undefined && channels.some(channel => channel.id === view.defaultChannelId)) return view.defaultChannelId
136
+ return channels[0]?.id ?? ''
137
+ }
138
+
139
+ private dirtyValue(): boolean {
140
+ const channels = this.channelsValue()
141
+ const stagedChanged = this.stagedChannels !== null && !deepEqualJson(this.stagedChannels, scopeChannelsOf(this.scope))
142
+ const scopeView = this.scope.getSnapshot().value as { defaultChannelId?: string } | undefined
143
+ const scopeDefault = scopeView?.defaultChannelId ?? channels[0]?.id ?? ''
144
+ const defaultChanged = this.stagedDefault !== null && this.stagedDefault !== scopeDefault
145
+ return stagedChanged || defaultChanged || this.stagedKeys.size > 0
146
+ }
147
+
148
+ /** The card-facing snapshot. */
149
+ snapshot(): ChannelsFormState {
150
+ const channels = this.channelsValue()
151
+ const keySet: Record<string, boolean> = {}
152
+ for (const channel of channels) keySet[channel.id] = this.keyHeld(channel.id)
153
+ return {
154
+ channels,
155
+ keySet,
156
+ defaultChannelId: this.defaultValue(),
157
+ dirty: this.dirtyValue(),
158
+ writable: this.scope.getSnapshot().writable !== false,
159
+ saving: this.saving,
160
+ failed: this.failed,
161
+ }
162
+ }
163
+
164
+ /** The actions the card's slot registration injects. */
165
+ actions(): ChannelsFormActions {
166
+ return {
167
+ setChannels: (channels) => { this.stageChannels(channels) },
168
+ setChannelKey: (id, value) => { this.stageKey(id, value) },
169
+ setDefaultChannel: (id) => { this.stagedDefault = id; this.failed = false; this.publish() },
170
+ commit: () => this.commit(),
171
+ discard: () => {
172
+ if (this.stagedChannels === null && this.stagedKeys.size === 0 && this.stagedDefault === null && !this.failed) return
173
+ this.stagedChannels = null
174
+ this.stagedKeys.clear()
175
+ this.stagedDefault = null
176
+ this.failed = false
177
+ this.publish()
178
+ },
179
+ }
180
+ }
181
+
182
+ // ------------------------------------------------------------------ staging
183
+
184
+ private stageChannels(channels: ChannelDraft[]): void {
185
+ const cleaned = channels.map(stripChannel)
186
+ this.stagedChannels = cleaned
187
+ this.failed = false
188
+ this.publish()
189
+ }
190
+
191
+ private stageKey(id: string, value: string | undefined): void {
192
+ if (value === undefined || value.trim() === '') {
193
+ if (this.keyHeld(id)) this.stagedKeys.set(id, { kind: 'clear' })
194
+ // An empty key for a key-less channel stages nothing.
195
+ } else {
196
+ this.stagedKeys.set(id, { kind: 'set', value: value.trim() })
197
+ }
198
+ this.failed = false
199
+ this.publish()
200
+ }
201
+
202
+ // ----------------------------------------------------------------- save
203
+
204
+ /** Build the single batch of path ops a save performs. */
205
+ private planOps(): SettingsOp[] {
206
+ const ops: SettingsOp[] = []
207
+ if (this.stagedChannels !== null) {
208
+ ops.push({ op: 'set', path: ['channels'], value: this.stagedChannels })
209
+ // Once channels exist, the legacy flat fields are obsolete (idempotent).
210
+ ops.push({ op: 'unset', path: ['apiUrl'] })
211
+ ops.push({ op: 'unset', path: ['apiKey'] })
212
+ ops.push({ op: 'unset', path: ['imageModels'] })
213
+ }
214
+ for (const [id, edit] of this.stagedKeys) {
215
+ if (edit.kind === 'set') ops.push({ op: 'set', path: ['channelSecrets', id], value: edit.value })
216
+ else ops.push({ op: 'unset', path: ['channelSecrets', id] })
217
+ }
218
+ if (this.stagedDefault !== null) {
219
+ ops.push({ op: 'set', path: ['defaultChannelId'], value: this.stagedDefault })
220
+ }
221
+ return ops
222
+ }
223
+
224
+ /**
225
+ * Write every staged edit, then re-seed from what the Host accepted.
226
+ * @returns settlement after the write settles.
227
+ */
228
+ async commit(): Promise<void> {
229
+ if (this.saving) return
230
+ const ops = this.planOps()
231
+ if (ops.length === 0) return
232
+ this.saving = true
233
+ this.failed = false
234
+ this.publish()
235
+ try {
236
+ await this.scope.mutateOps(ops)
237
+ this.stagedChannels = null
238
+ this.stagedKeys.clear()
239
+ this.stagedDefault = null
240
+ this.failed = false
241
+ } catch {
242
+ this.failed = true
243
+ } finally {
244
+ this.saving = false
245
+ this.publish()
246
+ }
247
+ }
248
+
249
+ private publish(): void {
250
+ for (const listener of [...this.listeners]) listener()
251
+ }
252
+ }
253
+
254
+ /** Project a stored channel into a draft (secrets never travel in channels). */
255
+ function toDraft(channel: ChannelConfig): ChannelDraft {
256
+ return { id: channel.id, preset: channel.preset, name: channel.name, apiUrl: channel.apiUrl, models: channel.models.map(model => ({ ...model })) }
257
+ }
258
+
259
+ /** The scope's current channels value (a plain array), for change detection. */
260
+ function scopeChannelsOf(scope: AudiogenScope): unknown {
261
+ const view = scope.getSnapshot().value as { channels?: unknown } | undefined
262
+ return Array.isArray(view?.channels) ? view.channels : []
263
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Audio panel controller: the single owner of the panel's open/closed state.
3
+ * Framework-free so the DOM mounts and the React panel share one tiny
4
+ * subscription surface.
5
+ */
6
+
7
+ export interface AudioGenControllerSnapshot {
8
+ panelOpen: boolean
9
+ }
10
+
11
+ export class AudioGenController {
12
+ private panelOpen = false
13
+ private listeners = new Set<() => void>()
14
+
15
+ getSnapshot(): AudioGenControllerSnapshot {
16
+ return { panelOpen: this.panelOpen }
17
+ }
18
+
19
+ subscribe(fn: () => void): () => void {
20
+ this.listeners.add(fn)
21
+ return () => { this.listeners.delete(fn) }
22
+ }
23
+
24
+ open(): void {
25
+ if (this.panelOpen) return
26
+ this.panelOpen = true
27
+ this.notify()
28
+ }
29
+
30
+ close(): void {
31
+ if (!this.panelOpen) return
32
+ this.panelOpen = false
33
+ this.notify()
34
+ }
35
+
36
+ toggle(): void {
37
+ if (this.panelOpen) this.close()
38
+ else this.open()
39
+ }
40
+
41
+ private notify(): void {
42
+ for (const fn of [...this.listeners]) fn()
43
+ }
44
+ }
@@ -0,0 +1,5 @@
1
+ /** CSS Modules type shim (the bundle inlines the compiled class map). */
2
+ declare module '*.module.css' {
3
+ const classes: Record<string, string>
4
+ export default classes
5
+ }