#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