Zum Inhalt

apple/PhysarumApp/Sources/Audio/AudioInput.swift

// 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
    }
}