Zum Inhalt

apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
#if !os(tvOS)
import SwiftUI
import UniformTypeIdentifiers
import PhysarumEngine

struct AudioReactiveSection: View {
    @Bindable var state: AppState

    @State private var showSaveAlert = false
    @State private var newPresetName = ""
    /// The audio config a menu "Export…" is currently writing out (drives `.fileExporter`).
    @State private var exportConfig: AudioConfig?
    @State private var showImporter = false
    /// Import-decode failures route through the app-wide alert (no inline text).
    @Environment(ErrorPresenter.self) private var errorPresenter

    private var ar: AudioReactive { state.audioReactive }

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s3) {
            HStack {
                HelpLabel("Enabled", help: ParamHelp.text(for: "Audio reactive"))
                Spacer()
                Toggle("", isOn: Binding(
                    get: { state.audioReactive.enabled },
                    set: { state.setAudioEnabled($0) })).labelsHidden()
                    .accessibilityLabel("Audio reactive")
                    .accessibilityHint("React to microphone sound")
                    .accessibilityValue(A11y.onOff(state.audioReactive.enabled))
            }
            HStack {
                HelpLabel("Auto-gain", help: ParamHelp.text(for: "Auto-gain"))
                Spacer()
                Toggle("", isOn: Binding(
                    get: { state.audioReactive.autoGain },
                    set: { state.audioReactive.autoGain = $0 })).labelsHidden()
                    .accessibilityLabel("Auto-gain")
                    .accessibilityHint("Auto-scale reaction to loudness")
                    .accessibilityValue(A11y.onOff(state.audioReactive.autoGain))
            }

            VStack(alignment: .leading, spacing: Theme.Space.s1) {
                HStack {
                    HelpLabel("Gain", help: ParamHelp.text(for: "Input gain"))
                    Spacer()
                    Text(String(format: "%.2fx", ar.gain)).font(Theme.Fonts.value)
                        .foregroundStyle(Theme.Colors.fgSecondary)
                }
                Slider(value: Binding(
                    get: { Double(state.audioReactive.gain) },
                    set: { state.audioReactive.gain = Float($0) }),
                    in: 0.1...10,
                    onEditingChanged: { _ in })
                    .tint(Theme.Colors.accent).controlSize(.small)
                    .accessibilityLabel("Input gain")
                    .accessibilityHint("Master reaction strength")
                    .accessibilityValue(String(format: "%.2f×", ar.gain))
            }

            meter
            savedConfigs
            routesEditor
            #if DEBUG
            tuningDebug
            #endif
        }
        .alert("Save audio config", isPresented: $showSaveAlert) {
            TextField("Name", text: $newPresetName)
            Button("Cancel", role: .cancel) { }
            Button("Save") {
                state.saveAudioPreset(name: newPresetName)
                newPresetName = ""
            }
        } message: {
            Text("Save the current routes + gain as a switchable config.")
        }
    }

    // Compact saved-config row: a menu of saved configs (apply on select, delete per row)
    // + a "Save current as…" button. Mirrors the preset SaveDialog without thumbnails.
    private var savedConfigs: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HStack(spacing: Theme.Space.s2) {
                Text("Saved").font(Theme.Fonts.label)
                Spacer()
                Menu {
                    if state.audioPresets.isEmpty {
                        Text("No saved configs")
                    } else {
                        ForEach(state.audioPresets) { cfg in
                            Button(cfg.name) { state.applyAudioPreset(id: cfg.id) }
                                .accessibilityLabel("Apply audio config")
                                .accessibilityHint("Switch to this saved routing")
                                .accessibilityValue(cfg.name)
                        }
                        Divider()
                        Menu("Export") {
                            ForEach(state.audioPresets) { cfg in
                                Button(cfg.name) { exportConfig = cfg }
                                    .accessibilityLabel("Export audio config")
                                    .accessibilityHint("Save this config to a file")
                                    .accessibilityValue(cfg.name)
                            }
                        }
                        Menu("Delete") {
                            ForEach(state.audioPresets) { cfg in
                                Button(cfg.name, role: .destructive) { state.deleteAudioPreset(id: cfg.id) }
                                    .accessibilityLabel("Delete audio config")
                                    .accessibilityHint("Remove this saved config")
                                    .accessibilityValue(cfg.name)
                            }
                        }
                    }
                    Divider()
                    Button("Import…") { showImporter = true }
                        .accessibilityLabel("Import audio config")
                        .accessibilityHint("Load a config from a file")
                } label: {
                    Image(systemName: "list.bullet")
                }
                .menuStyle(.borderlessButton).fixedSize()
                .foregroundStyle(Theme.Colors.accent)
                .help("Apply, export, delete, or import a saved audio config")

                Button("Save current as…") {
                    newPresetName = ""
                    showSaveAlert = true
                }
                .font(Theme.Fonts.eyebrow).buttonStyle(.plain).foregroundStyle(Theme.Colors.accent)
                .help("Save the current routes + gain as a switchable config")
                .accessibilityLabel("Save audio config")
                .accessibilityHint("Save the current routing as new")
            }
        }
        // Export + import on ALL platforms (Files app handles iOS; no macOS-only gate).
        .fileExporter(
            isPresented: Binding(get: { exportConfig != nil }, set: { if !$0 { exportConfig = nil } }),
            document: exportConfig.map { AudioConfigDocument(config: $0) },
            contentType: .json,
            defaultFilename: exportConfig.map { slug($0.name) }
        ) { _ in exportConfig = nil }
        .fileImporter(isPresented: $showImporter, allowedContentTypes: [.json]) { result in
            guard case .success(let url) = result else { return }
            let scoped = url.startAccessingSecurityScopedResource()
            defer { if scoped { url.stopAccessingSecurityScopedResource() } }
            guard let data = try? Data(contentsOf: url),
                  let cfg = try? JSONDecoder().decode(AudioConfig.self, from: data) else {
                errorPresenter.report(title: "Couldn’t Import Audio Config",
                                      message: "That file isn’t a valid Physarum audio config.")
                return
            }
            state.importAudioConfig(cfg)
        }
    }

    /// Filesystem-safe slug for an export filename (mirrors AppState's id slug).
    private func slug(_ s: String) -> String {
        let mapped = s.lowercased().map { ch -> Character in ch.isLetter || ch.isNumber ? ch : "-" }
        let collapsed = String(mapped).split(separator: "-").joined(separator: "-")
        return collapsed.isEmpty ? "audio" : collapsed
    }

    private var routesEditor: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s2) {
            HStack {
                Text("Routes").font(Theme.Fonts.label)
                Spacer()
                Button("Default") { state.applyDefaultAudioWiring() }
                    .font(Theme.Fonts.eyebrow).buttonStyle(.plain).foregroundStyle(Theme.Colors.accent)
                    .help("Apply a starter set of routes for instant, punchy reactivity")
                    .accessibilityLabel("Apply default wiring")
                    .accessibilityHint("Restore the starter routes")
                Button {
                    if state.audioReactive.routes.count < 16 {
                        state.audioReactive.routes.append(
                            AudioRoute(source: .bass, target: .moveSpeed, depth: 1, mode: .transient))
                    }
                } label: { Image(systemName: "plus.circle") }
                .buttonStyle(.plain).foregroundStyle(Theme.Colors.accent)
                .disabled(state.audioReactive.routes.count >= 16)
                .accessibilityLabel("Add audio route")
                .accessibilityHint("Create a source→target modulation")
            }
            if state.audioReactive.routes.isEmpty {
                Text("No routes — audio has no effect until you add one.")
                    .font(Theme.Fonts.eyebrow).foregroundStyle(Theme.Colors.fgTertiary)
            }
            ForEach(Array(state.audioReactive.routes.enumerated()), id: \.offset) { idx, _ in
                routeRow(idx)
            }
        }
    }

    @ViewBuilder
    private func routeRow(_ idx: Int) -> some View {
        if idx < state.audioReactive.routes.count {
            VStack(spacing: Theme.Space.s1) {
                // Line 1: source → target · remove. Source/target are custom Menus whose
                // labels truncate — a plain `.menu` Picker label ignores `lineLimit` and wraps
                // char-by-char in a narrow panel (the bug this replaces).
                HStack(spacing: Theme.Space.s2) {
                    bandMenu(idx)
                    routeArrow
                    targetMenu(idx)
                    removeButton(idx)
                }
                // Line 2: mode (natural width) · depth slider (fills) · value.
                HStack(spacing: Theme.Space.s2) {
                    modePicker(idx).fixedSize()
                    depthSlider(idx)
                    depthValue(idx)
                }
            }
            .padding(Theme.Space.s2)
            .background(Theme.Colors.overlay, in: RoundedRectangle(cornerRadius: Theme.Radius.md))
        }
    }

    private var routeArrow: some View {
        Image(systemName: "arrow.right").font(.system(size: 9)).foregroundStyle(Theme.Colors.fgTertiary)
    }

    /// A pill dropdown whose label truncates (tail) instead of wrapping.
    /// iOS/iPad only — macOS uses native pop-up buttons (see bandMenu/targetMenu).
    #if !os(macOS)
    private func menuChip(_ text: String, fill: Bool = false) -> some View {
        HStack(spacing: 3) {
            Text(text).font(Theme.Fonts.label).lineLimit(1).truncationMode(.tail)
            Image(systemName: "chevron.down").font(.system(size: 8)).foregroundStyle(Theme.Colors.fgTertiary)
        }
        .padding(.horizontal, Theme.Space.s2).padding(.vertical, 4)
        .frame(maxWidth: fill ? .infinity : nil, alignment: .leading)
        .background(Theme.Colors.overlay, in: Capsule())
        .foregroundStyle(Theme.Colors.fgSecondary)
        .contentShape(Capsule())
    }
    #endif

    private func sourceBinding(_ idx: Int) -> Binding<AudioBand> {
        Binding(
            get: { idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].source : .bass },
            set: { if idx < state.audioReactive.routes.count { state.audioReactive.routes[idx].source = $0 } })
    }

    private func targetBinding(_ idx: Int) -> Binding<ModTarget> {
        Binding(
            get: { idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].target : .moveSpeed },
            set: { if idx < state.audioReactive.routes.count { state.audioReactive.routes[idx].target = $0 } })
    }

    private func bandMenu(_ idx: Int) -> some View {
        Group {
            #if os(macOS)
            Picker("", selection: sourceBinding(idx)) {
                ForEach(AudioBand.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
            }
            .labelsHidden().pickerStyle(.menu)
            #else
            Menu {
                Picker("Source", selection: sourceBinding(idx)) {
                    ForEach(AudioBand.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
                }
            } label: {
                menuChip((idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].source : .bass).rawValue.capitalized)
            }
            #endif
        }
        .accessibilityLabel("Source")
        .accessibilityValue((idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].source : .bass).rawValue.capitalized)
    }

    private func targetMenu(_ idx: Int) -> some View {
        Group {
            #if os(macOS)
            Picker("", selection: targetBinding(idx)) {
                Section("Agent") { ForEach(Self.agentTargets, id: \.self) { Text(shortTarget($0)).tag($0) } }
                Section("Post") { ForEach(Self.postTargets, id: \.self) { Text(shortTarget($0)).tag($0) } }
            }
            .labelsHidden().pickerStyle(.menu).frame(maxWidth: .infinity)
            #else
            Menu {
                Picker("Target", selection: targetBinding(idx)) {
                    Section("Agent") { ForEach(Self.agentTargets, id: \.self) { Text(shortTarget($0)).tag($0) } }
                    Section("Post") { ForEach(Self.postTargets, id: \.self) { Text(shortTarget($0)).tag($0) } }
                }
            } label: {
                menuChip(shortTarget(idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].target : .moveSpeed), fill: true)
            }
            #endif
        }
        .accessibilityLabel("Target")
        .accessibilityValue(shortTarget(idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].target : .moveSpeed))
    }

    private func modePicker(_ idx: Int) -> some View {
        Picker("", selection: Binding<RouteMode>(
            get: {
                guard idx < state.audioReactive.routes.count else { return .level }
                return state.audioReactive.routes[idx].mode
            },
            set: { newValue in
                guard idx < state.audioReactive.routes.count else { return }
                state.audioReactive.routes[idx].mode = newValue
            })) {
            Text("Lvl").tag(RouteMode.level)
            Text("Trans").tag(RouteMode.transient)
        }
        .pickerStyle(.segmented).accessibilityLabel("Mode")
        .accessibilityHint("Level or transient")
        .accessibilityValue((idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].mode : .level) == .transient ? "Transient" : "Level")
    }

    private func depthSlider(_ idx: Int) -> some View {
        Slider(value: Binding(
            get: {
                guard idx < state.audioReactive.routes.count else { return 0 }
                return Double(state.audioReactive.routes[idx].depth)
            },
            set: {
                guard idx < state.audioReactive.routes.count else { return }
                state.audioReactive.routes[idx].depth = Float($0)
            }),
            in: -2...2,
            onEditingChanged: { _ in })
            .tint(Theme.Colors.accent).controlSize(.small).accessibilityLabel("Depth")
            .accessibilityValue(String(format: "%+.1f", idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].depth : 0))
    }

    private func depthValue(_ idx: Int) -> some View {
        Text(String(format: "%+.1f", idx < state.audioReactive.routes.count ? state.audioReactive.routes[idx].depth : 0))
            .font(Theme.Fonts.value).foregroundStyle(Theme.Colors.fgSecondary)
            // Fixed-width numeric cell: shrink rather than clip at large Dynamic Type.
            .lineLimit(1).minimumScaleFactor(0.7).frame(width: 34)
    }

    private func removeButton(_ idx: Int) -> some View {
        Button {
            guard idx < state.audioReactive.routes.count else { return }
            state.audioReactive.routes.remove(at: idx)
        } label: { Image(systemName: "minus.circle") }
        .buttonStyle(.plain).foregroundStyle(Theme.Colors.fgTertiary)
        .accessibilityLabel("Remove route")
        .accessibilityHint("Delete this modulation")
    }

    private func shortTarget(_ t: ModTarget) -> String {
        switch t {
        case .moveSpeed: return "Move"; case .turnSpeed: return "Turn"
        case .depositAmount: return "Deposit"; case .sensorDistance: return "Sensor"
        case .rhythmAmplitude: return "Rhythm"; case .chaosStrength: return "Chaos"
        case .exposure: return "Expo"; case .bloomIntensity: return "Bloom"
        case .saturation: return "Sat"; case .contrast: return "Contr"
        }
    }

    private static let agentTargets: [ModTarget] = [.moveSpeed, .turnSpeed, .depositAmount, .sensorDistance, .rhythmAmplitude, .chaosStrength]
    private static let postTargets: [ModTarget] = [.exposure, .bloomIntensity, .saturation, .contrast]

    private var meter: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            meterBar("Bass", state.audio.meterLevels.bass)
            meterBar("Mid", state.audio.meterLevels.mid)
            meterBar("High", state.audio.meterLevels.high)
        }
    }

    private func meterBar(_ label: String, _ level: Float) -> some View {
        HStack(spacing: Theme.Space.s2) {
            Text(label).font(Theme.Fonts.eyebrow).foregroundStyle(Theme.Colors.fgTertiary)
                // Fixed-width label cell: shrink at large Dynamic Type rather than clip.
                .lineLimit(1).minimumScaleFactor(0.7)
                .frame(width: 40, alignment: .leading)
            GeometryReader { geo in
                ZStack(alignment: .leading) {
                    Capsule().fill(Theme.Colors.overlay)
                    Capsule().fill(Theme.Colors.accent)
                        .frame(width: geo.size.width * CGFloat(min(1, max(0, level))))
                }
            }
            .frame(height: 6)
        }
    }

    #if DEBUG
    // Device-tuning rig for the loudness-response curve. DEBUG-only: compiled out of
    // release. Live-mutates the global `audioTuning` (persisted, applied per frame via
    // RenderCoordinator) so values can be dialled in on-device without a rebuild — read
    // the numbers off the value labels and bake the keepers into AudioTuning's defaults.
    @State private var tuningExpanded = true

    private var tuningDebug: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s2) {
            Button {
                withAnimation(.easeOut(duration: 0.18)) { tuningExpanded.toggle() }
            } label: {
                HStack {
                    Text("TUNING (DEBUG)").font(Theme.Fonts.eyebrow).tracking(1.6)
                        .foregroundStyle(Theme.Colors.fgTertiary)
                    Spacer()
                    Image(systemName: tuningExpanded ? "chevron.down" : "chevron.right")
                        .font(.system(size: 9, weight: .bold)).foregroundStyle(Theme.Colors.fgTertiary)
                }
            }
            .buttonStyle(.plain)
            if tuningExpanded {
                // Wrapped so the revealed knobs collapse/expand as one unit and share the
                // section's s3 control rhythm (the header sits tighter at s2 above).
                VStack(alignment: .leading, spacing: Theme.Space.s3) {
                    tuningSlider("Env coef", a11yLabel: "Envelope coefficient", a11yHint: "Loudness smoothing", value: Binding(
                        get: { Double(state.audioTuning.envCoef) },
                        set: { state.audioTuning.envCoef = Float($0) }), range: 0.01...0.6)
                    tuningSlider("Gate lo", a11yLabel: "Gate low", a11yHint: "Silence lower bound", value: Binding(
                        get: { Double(state.audioTuning.gateLo) },
                        set: { state.audioTuning.gateLo = Float($0) }), range: 0...0.2)
                    // Keep gateHi above gateLo for a smooth ramp — below it, smoothstep degrades to a hard step.
                    tuningSlider("Gate hi", a11yLabel: "Gate high", a11yHint: "Silence upper bound", value: Binding(
                        get: { Double(state.audioTuning.gateHi) },
                        set: { state.audioTuning.gateHi = Float($0) }), range: 0...0.6)
                    Divider().overlay(Theme.Colors.borderSubtle)
                    Toggle(isOn: Binding(
                        get: { state.audioTuning.adaptiveBalance },
                        set: { state.audioTuning.adaptiveBalance = $0 })) {
                        Text("Adaptive balance").font(Theme.Fonts.label)
                            .foregroundStyle(Theme.Colors.fgSecondary)
                    }
                    .accessibilityHint("A/B the per-band balance adaptation")
                    tuningSlider("Peak tau", a11yLabel: "Peak tau", a11yHint: "Peak follower release", value: Binding(
                        get: { Double(state.audioTuning.peakTau) },
                        set: { state.audioTuning.peakTau = Float($0) }), range: 0.5...15)
                    tuningSlider("Valley tau", a11yLabel: "Valley tau", a11yHint: "Valley follower rise", value: Binding(
                        get: { Double(state.audioTuning.valleyTau) },
                        set: { state.audioTuning.valleyTau = Float($0) }), range: 1...30)
                    tuningSlider("Band gate lo", a11yLabel: "Band gate low", a11yHint: "Band presence lower bound", value: Binding(
                        get: { Double(state.audioTuning.bandGateLo) },
                        set: { state.audioTuning.bandGateLo = Float($0) }), range: 0...0.2)
                    tuningSlider("Band gate hi", a11yLabel: "Band gate high", a11yHint: "Band presence upper bound", value: Binding(
                        get: { Double(state.audioTuning.bandGateHi) },
                        set: { state.audioTuning.bandGateHi = Float($0) }), range: 0...0.4)
                    tuningSlider("Min span", a11yLabel: "Minimum span", a11yHint: "Noise guard", value: Binding(
                        get: { Double(state.audioTuning.minSpan) },
                        set: { state.audioTuning.minSpan = Float($0) }), range: 0.01...0.3)
                    Button("Reset") { state.resetTuning() }
                        .font(Theme.Fonts.eyebrow).buttonStyle(.plain).foregroundStyle(Theme.Colors.accent)
                        .accessibilityLabel("Reset tuning")
                        .accessibilityHint("Restore tuning defaults")
                }
            }
        }
    }

    private func tuningSlider(_ label: String, a11yLabel: String, a11yHint: String,
                              value: Binding<Double>,
                              range: ClosedRange<Double>) -> some View {
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HStack {
                Text(label).font(Theme.Fonts.label).foregroundStyle(Theme.Colors.fgSecondary)
                Spacer()
                Text(String(format: "%.3f", value.wrappedValue)).font(Theme.Fonts.value)
                    .foregroundStyle(Theme.Colors.fgSecondary)
            }
            Slider(value: value, in: range, onEditingChanged: { _ in })
                .tint(Theme.Colors.accent).controlSize(.small)
                .accessibilityLabel(a11yLabel)
                .accessibilityHint(a11yHint)
                .accessibilityValue(String(format: "%.3f", value.wrappedValue))
        }
    }
    #endif
}
#endif