// apple/PhysarumApp/Sources/Audio/AudioInput.swift
import Foundation
import AVFoundation
import os
/// Microphone capture + real-time band analysis. The tap runs on the audio
/// render thread (vDSP only), publishing bands into a lock-guarded slot the
/// render loop pulls each frame. `meterLevels` is a main-thread @Observable
/// mirror for the UI meter.
@Observable
final class AudioInput {
private let engine = AVAudioEngine()
private let analyzer = AudioAnalyzer(fftSize: 1024, smoothing: 0.8)
@ObservationIgnored private let slot = OSAllocatedUnfairLock(initialState: AudioBands.zero)
// Written on main (the render loop, next to setEnabled), read on the audio thread in
// the tap — the same lock-guarded hand-off the bands slot uses in the other direction.
@ObservationIgnored private let tuningSlot = OSAllocatedUnfairLock(initialState: AudioTuning())
private(set) var running = false
@ObservationIgnored private var starting = false
// Failure caches: setEnabled(true) is called every frame by the render loop,
// so without these a denied permission would re-issue the permission IPC at
// 60Hz and a failed engine start would retry (and re-install the tap) forever.
// Observable (no @ObservationIgnored) so the UI can surface a denial: the
// render loop reads it as a plain flag; SwiftUI observes the false→true edge.
private(set) var permissionDenied = false
@ObservationIgnored private var startFailures = 0
var meterLevels = AudioBands.zero
@ObservationIgnored private var meterTick = 0
/// Lock-guarded read for the render loop.
var latestBands: AudioBands { slot.withLock { $0 } }
/// Hands the current tuning to the analyzer. Idempotent and cheap — the render loop
/// calls this every frame alongside `setEnabled`.
func setTuning(_ t: AudioTuning) { tuningSlot.withLock { $0 = t } }
/// Idempotent. Requests permission on first enable; starts/stops the engine.
/// Must be called on the main thread — see `dispatchPrecondition` below.
func setEnabled(_ on: Bool) {
// AVAudioEngine + the running/starting flags must only be touched on the main
// thread. The sole caller is RenderCoordinator.draw(in:), which the default
// MTKView display link invokes on main. This precondition pins that contract so
// a future off-main draw refactor fails loudly in debug instead of racing.
dispatchPrecondition(condition: .onQueue(.main))
on ? start() : stop()
}
private func start() {
guard !running, !starting, !permissionDenied, startFailures < 3 else { return }
starting = true
requestPermission { [weak self] granted in
guard let self else { return }
self.permissionDenied = !granted
if granted { self.beginTap() }
else { self.starting = false }
}
}
private func beginTap() {
defer { starting = false }
guard !running else { return }
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.record, mode: .measurement, options: [.mixWithOthers])
try? session.setActive(true)
#endif
let input = engine.inputNode
let format = input.inputFormat(forBus: 0)
guard format.sampleRate > 0 else { return }
let sr = format.sampleRate
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
guard let self, let ch = buffer.floatChannelData else { return }
let n = Int(buffer.frameLength)
let samples = Array(UnsafeBufferPointer(start: ch[0], count: n))
let tuning = self.tuningSlot.withLock { $0 }
let bands = self.analyzer.analyze(samples: samples, sampleRate: sr, tuning: tuning)
self.slot.withLock { $0 = bands }
self.publishMeter(bands)
}
engine.prepare()
do { try engine.start(); running = true; startFailures = 0 }
catch {
// Remove the tap so a later retry can install cleanly (a second
// installTap on the same bus raises an NSException), and cap the
// retries — the render loop would otherwise re-attempt at 60Hz.
input.removeTap(onBus: 0)
running = false
startFailures += 1
}
}
private func stop() {
guard running else { return }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
#if os(iOS)
// Release the record session — holding it active with the engine stopped
// keeps audio-input infrastructure powered for nothing.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
running = false
startFailures = 0 // a fresh user toggle may retry
slot.withLock { $0 = .zero }
Task { @MainActor in self.meterLevels = .zero }
}
private func publishMeter(_ bands: AudioBands) {
meterTick &+= 1
guard meterTick % 4 == 0 else { return }
Task { @MainActor in self.meterLevels = bands }
}
private func requestPermission(_ done: @escaping (Bool) -> Void) {
#if os(iOS)
AVAudioApplication.requestRecordPermission { ok in DispatchQueue.main.async { done(ok) } }
#elseif os(macOS)
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized: DispatchQueue.main.async { done(true) }
case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { ok in DispatchQueue.main.async { done(ok) } }
default: DispatchQueue.main.async { done(false) }
}
#else
// tvOS: no microphone — never grant, so the engine stays audio-neutral.
DispatchQueue.main.async { done(false) }
#endif
}
}