Zum Inhalt

apple/PhysarumApp/Sources/Remote/RemoteController.swift

import SwiftUI
import Network
import PhysarumEngine

/// The phone side: owns the link to the ATV and forwards the local editing session — selection and
/// intents immediately, edited params throttled (~20 Hz via `RemoteSendGate`). It is the *source*,
/// so it ignores inbound messages; it only receives to keep the connection healthy. `@Observable` so
/// the UI can reflect `state`. The view drives it from `AppState` change hooks (`onChange`).
@Observable
final class RemoteController {
    enum ConnectionState: Equatable { case disconnected, connecting, connected, failed }

    private(set) var state: ConnectionState = .disconnected

    private var link: RemoteLink?
    private var gate = RemoteSendGate(minParamInterval: 0.05)
    private var audioGate = RemoteSendGate(minParamInterval: 0.030)   // ≈33 Hz cap; the 30 Hz view timer paces it
    private var streamAutoGain = AutoGainState()                       // own loudness follower for the streamed factors
    /// Monotonic clock for the throttle (injectable for completeness; the gate itself is host-tested).
    private let now: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }

    /// Dial the chosen ATV and, once ready, send the full current session so the ATV mirrors the phone.
    func connect(_ connection: NWConnection, initialState: RemoteState) {
        link?.cancel()
        state = .connecting
        let link = RemoteLink(connection: connection)
        link.onStateChange = { [weak self] nwState in
            guard let self else { return }
            switch nwState {
            case .ready:
                self.state = .connected
                self.link?.send(.hello(initialState))
            case .failed:
                self.state = .failed
                self.link = nil
            case .cancelled:
                self.state = .disconnected
                self.link = nil
            default:
                break
            }
        }
        link.onMessage = { _ in }   // the phone is the source; ignore inbound
        self.link = link
        link.start()
    }

    func disconnect() {
        link?.cancel()
        link = nil
        state = .disconnected
    }

    /// A preset selection (or its live params) — sent as a full `hello` so the ATV crossfades to the
    /// new structure AND adopts the phone's current params (WYSIWYG), not just the preset baseline.
    func sendSelection(id: String, params: PresetParams, cycling: Bool) {
        guard state == .connected else { return }
        link?.send(.hello(.init(selectedID: id, params: params, cycling: cycling)))
    }

    func sendIntent(_ intent: ControlIntent) {
        guard state == .connected else { return }
        link?.send(.intent(intent))
    }

    /// A live param edit — throttled so a slider drag doesn't flood the LAN.
    func sendParams(_ params: PresetParams) {
        guard state == .connected, gate.allowParams(at: now()) else { return }
        link?.send(.params(params))
    }

    /// Stream the current mic reactivity to the ATV as the 10 audio factors (throttled, ~30 Hz). The
    /// factors are recomputed here via the pure `evaluateAudioFactors` (decoupled from the phone's own
    /// render loop) using a private autoGain follower, so the ATV reacts with the phone's full feel.
    func sendAudioFactors(bands: AudioBands, reactive: AudioReactive, tuning: AudioTuning) {
        guard state == .connected, audioGate.allowParams(at: now()) else { return }
        // evaluateAudioFactors mutates the loudness EMA in `streamAutoGain`; called on main (the 30 Hz
        // view timer), and the only other call site (RenderCoordinator.applyAudio) is also main-only.
        let f = evaluateAudioFactors(bands: bands, reactive: reactive, autoGain: &streamAutoGain, tuning: tuning)
        link?.send(.audioFactors(AudioFactorFrame(f)))
    }

    /// One neutral frame on a clean stop (audio toggled off) so the ATV relaxes immediately rather than
    /// holding the last beat until its staleness timer fires.
    func sendAudioFactorsNeutral() {
        guard state == .connected else { return }
        link?.send(.audioFactors(.neutral))
    }
}