// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later

package space.rcmd.android.sizzle.audio

import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import space.rcmd.android.sizzle.model.Arrangement
import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.SamplerPads
import space.rcmd.android.sizzle.model.TimeDivision
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.model.ToolboxType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.thread
import kotlin.math.abs
import kotlin.math.sin
import kotlin.math.tanh

/**
 * The real-time sound engine. It owns one [AudioTrack] and a dedicated render
 * thread that is completely separate from the UI thread — this is what keeps
 * playback smooth while the interface is being touched.
 *
 * The render loop is a classic tracker "sample-accurate sequencer": it counts
 * audio samples and, exactly when enough samples have elapsed for one tracker
 * line, it advances the playhead and triggers that line's notes. Because timing
 * is derived from the audio clock (not `Handler.postDelayed`), there is no drift.
 *
 * PLAYBACK MODES
 * --------------
 *  - ARRANGEMENT mode (default when the arrangement has any blocks): the
 *    sequencer walks a pre-expanded playlist of beats (with A/B loop repeats),
 *    and at each beat every filled lane plays one beat of its pattern. A lane
 *    keeps its own cursor so a block spanning several consecutive beats advances
 *    through the pattern beat-by-beat.
 *  - PATTERN-LOOP mode (when the arrangement is empty): the currently-edited
 *    pattern loops, so the Tracker tab is always audible while you edit.
 *
 * Voices are pooled PER MIXER BUS: each of the [TRACK_COUNT] buses owns a pool of
 * [POLYPHONY] synth + [POLYPHONY] sample voices, so a single instrument can sound
 * up to [POLYPHONY] notes at once (chords, overlapping release tails) rather than
 * one. Notes are allocated round-robin, stealing the oldest voice when the pool is
 * full — the requested "N sounds per instrument" cap.
 *
 * INSTRUMENTS: the subtractive [SynthVoice] (NES synth), the Sampler and the
 * SoundFont are all rendered here. Each bus pool has both [SynthVoice]s and
 * [SampleVoice]s; the channel's instrument type decides which one a note triggers.
 * Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
 * bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
 * Kotlin/AudioTrack loop for an Oboe (C++/AAudio) engine is a later milestone.
 */
class AudioEngine(
    private val sampleRate: Int = 48_000,
) {
    // Sequencer voices: a per-mixer-bus pool of POLYPHONY synth + POLYPHONY sample
    // voices, laid out as bus*POLYPHONY + i. A note plays whatever instrument its
    // channel routes to, allocated from that bus's pool, so one instrument can sound
    // several notes simultaneously instead of stealing its single voice every time.
    private val seqVoices = Array(Pattern.TRACK_COUNT * POLYPHONY) { SynthVoice(sampleRate) }
    private val sampleVoices = Array(Pattern.TRACK_COUNT * POLYPHONY) { SampleVoice(sampleRate) }
    // Round-robin steal cursors per bus (which pool slot to reuse when all are busy).
    private val synthAlloc = IntArray(Pattern.TRACK_COUNT)
    private val sampleAlloc = IntArray(Pattern.TRACK_COUNT)
    // ... plus a small pool for live auditioning from keyboard / MIDI / pads.
    private val liveVoices = Array(LIVE_POLYPHONY) { SynthVoice(sampleRate) }
    // Sample voices for previewing sampler instruments (toolbox keyboard / editor).
    private val auditionSampleVoices = Array(AUDITION_POLYPHONY) { SampleVoice(sampleRate) }
    // Live "mix bus" audition voices: polyphony per mixer channel, laid out as
    // channel*BUS_POLYPHONY + i. Rendered INTO the channel sum (pre-FX) so playing
    // one auditions the whole bus — the channel's instrument through its FX chain,
    // volume, and mute/solo. Driven by the toolbox MIDI piano via busNoteOn/Off.
    private val busVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SynthVoice(sampleRate) }
    private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
    // Per-(lane,track) arpeggiator state, driven by advanceArps.
    private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
    // Per-(lane,track) MIDI channel the track last played a note on. A note-off cell
    // is tied to its track (it has no channel of its own) and releases whatever the
    // track last started, on the bus(es) that channel routed to. -1 = nothing yet.
    private val trackLastChannel = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }

    // Cached subtractive-synth patch per bus, reparsed only when the slot changes
    // (version) or tempo changes — so note triggers don't allocate a patch each time,
    // and active voices can be refreshed per block for live parameter feedback.
    private val synthPatch = arrayOfNulls<SynthPatch>(Pattern.TRACK_COUNT)
    private val synthPatchVer = IntArray(Pattern.TRACK_COUNT) { -1 }
    private val synthPatchTempo = FloatArray(Pattern.TRACK_COUNT) { Float.NaN }
    // Same, for the toolbox editor's audition (liveVoices) — tracks the slot currently
    // being auditioned so live knob turns are heard while a preview note is held.
    private var auditionSlotIdx = -1
    private var auditionPatch: SynthPatch? = null
    private var auditionPatchVer = -1
    private var auditionPatchTempo = Float.NaN

    /** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. The
     *  chosen voice is queued into this block's active list so a note started mid-block
     *  is rendered from the same sample (see [queueSeq]). */
    private fun allocSynthVoice(bus: Int): SynthVoice {
        val base = bus * POLYPHONY
        for (i in 0 until POLYPHONY) seqVoices[base + i].let { if (!it.isActive) { queueSeq(base + i); return it } }
        val idx = synthAlloc[bus]
        synthAlloc[bus] = (idx + 1) % POLYPHONY
        return seqVoices[base + idx].also { it.kill(); queueSeq(base + idx) }
    }

    /** Grab a free sample voice from [bus]'s pool, or steal the round-robin one.
     *  The stolen voice is NOT killed here — [SampleVoice.noteOn] declicks the hand-off
     *  itself (an abrupt kill of a loud voice would click). */
    private fun allocSampleVoice(bus: Int): SampleVoice {
        val base = bus * POLYPHONY
        for (i in 0 until POLYPHONY) sampleVoices[base + i].let { if (!it.isActive) { queueSmp(base + i); return it } }
        val idx = sampleAlloc[bus]
        sampleAlloc[bus] = (idx + 1) % POLYPHONY
        return sampleVoices[base + idx].also { queueSmp(base + idx) }
    }

    /** Append a sequencer synth/sample voice index to this block's active list, unless
     *  it is already queued this block (generation stamp) — dedup prevents a finished-
     *  then-reallocated voice from being rendered twice in one block. Audio-thread only
     *  (called from [playNote] via the allocators, all inside [fillBlock]). */
    private fun queueSeq(i: Int) { if (seqGen[i] != blockGen) { seqGen[i] = blockGen; actSeq[actSeqN++] = i } }
    private fun queueSmp(i: Int) { if (smpGen[i] != blockGen) { smpGen[i] = blockGen; actSmp[actSmpN++] = i } }

    /** Release (fade out) every active voice on [bus] — a note-off cell on its channel. */
    private fun releaseBus(bus: Int) {
        val base = bus * POLYPHONY
        for (i in 0 until POLYPHONY) {
            seqVoices[base + i].noteOff()
            sampleVoices[base + i].noteOff()
        }
    }

    /** One insert-effect instance bound to the toolbox slot that configures it. */
    private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect) {
        // Last slot version / tempo pushed into the effect, so [updateFxParams] can
        // skip re-parsing params (which allocates) when nothing changed.
        var lastVersion = -1
        var lastTempo = Float.NaN
    }

    /** Per-channel effect chains (mixer FX slots resolved to real processors).
     *  Rebuilt when routing changes; parameters refresh live every block. Stored as
     *  arrays (not lists) because the audio thread iterates them per sample, and a
     *  `for (u in list)` allocates an Iterator every time — a per-block heap churn
     *  that caused GC-pause dropouts. Array for-loops allocate nothing. Each rebuild
     *  swaps in a fresh array (reference assignment is atomic), like [playlist]. */
    @Volatile private var channelChains: Array<Array<FxUnit>> =
        Array(Pattern.TRACK_COUNT) { emptyArray() }

    /** Scratch buffer: per-channel sum for one sample (audio-thread only). */
    private val channelSum = FloatArray(Pattern.TRACK_COUNT)

    // Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
    // the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
    // never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
    // scanned every sample before). Each list holds global voice indices; the bus for a
    // seq/sample/bus voice is index / POLYPHONY (or / BUS_POLYPHONY).
    private val actSeq = IntArray(seqVoices.size); private var actSeqN = 0
    private val actSmp = IntArray(sampleVoices.size); private var actSmpN = 0
    private val actBus = IntArray(busVoices.size); private var actBusN = 0
    private val actBSmp = IntArray(busSampleVoices.size); private var actBSmpN = 0
    private val actLive = IntArray(liveVoices.size); private var actLiveN = 0
    private val actASmp = IntArray(auditionSampleVoices.size); private var actASmpN = 0
    // The sequencer can start a note mid-block; its voice must join this block's list so
    // it sounds sample-accurately (not one block late). [queueSeq]/[queueSmp] append it,
    // deduped by a per-voice generation stamp: a voice already queued this block (stamp
    // == [blockGen]) is not added twice — which would otherwise double-render (and
    // double the amplitude of) a voice that finished and was re-allocated within a block.
    private val seqGen = IntArray(seqVoices.size)
    private val smpGen = IntArray(sampleVoices.size)
    private var blockGen = 0

    /** The song being played. Swapped atomically; read on the audio thread. */
    @Volatile private var project: Project? = null

    @Volatile private var playing = false
    @Volatile private var running = false

    // ---- Shared position state (audio-thread only) ----
    private var samplesIntoLine = 0.0
    private var pendingTrigger = true
    /** Samples per tracker line, refreshed once per block (see [fillBlock]). */
    private var blockSamplesPerLine = 1.0

    // ---- Pattern-loop mode ----
    private var currentLine = 0

    // ---- Arrangement mode ----
    private var arrangementMode = false
    /** Beat indices to play, in order, with A/B repeats already expanded. Volatile
     *  and only ever swapped as a whole reference, so it can be rebuilt live from
     *  the main thread (e.g. a repeat-count change) while the audio thread reads a
     *  consistent snapshot — see [refreshLoopPlaylist] and [advanceArrangement]. */
    @Volatile private var playlist: IntArray = IntArray(0)
    private var playlistIndex = 0
    private var lineInBeat = 0
    /** Local tick of the *edited* block at the current beat, for the tracker
     *  playhead. Updated as that block sounds; retained when it isn't. */
    private var arrPlayLine = 0

    private val _transport = MutableStateFlow(TransportState())
    val transport: StateFlow<TransportState> = _transport

    private var renderThread: Thread? = null
    private var track: AudioTrack? = null

    // ------------------------------------------------ master-bus recording
    // Captures the final mixed output (post limiter) to a WAV file. Recording is
    // "armed" from the mixer toolbar and actually begins when playback starts; it
    // ends automatically a configurable FX-tail (in bars) after the sequencer
    // stops — whether that stop is a pause/stop button or the arrangement reaching
    // its end with looping off.
    private val masterRecorder = MasterRecorder(sampleRate)

    /** Directory for the staging temp WAV (the app's cache dir); set at startup. */
    var recTempDir: java.io.File? = null

    /** Invoked (on the writer thread) with the finished temp WAV and the recording's
     *  start epoch-millis when a take finalizes. The app copies it to its destination. */
    var onRecordingComplete: ((java.io.File, Long) -> Unit)? = null

    /** Invoked (on the main thread, from [play]) the moment a take starts capturing. */
    var onRecordingStarted: (() -> Unit)? = null

    @Volatile var recordArmed = false; private set
    @Volatile private var recTailBars = 0

    /** Arm/disarm recording and set the FX-tail length (bars) captured after a stop. */
    fun setRecordingArm(armed: Boolean, tailBars: Int) {
        recordArmed = armed
        recTailBars = tailBars.coerceIn(0, 8)
    }

    /** Update just the FX-tail length (bars) captured after the sequencer stops. */
    fun setRecordTailBars(tailBars: Int) { recTailBars = tailBars.coerceIn(0, 8) }

    // Audio-thread recording state machine (see [recordSample]). Initialized in
    // [maybeStartRecording] before the volatile [recCapturing] flag publishes them.
    @Volatile private var recCapturing = false
    private var recWasPlaying = false
    private var recTailRemaining = 0

    /** If armed and a destination temp dir is set, start capturing the master bus. */
    private fun maybeStartRecording() {
        if (!recordArmed) return
        val dir = recTempDir ?: return
        if (masterRecorder.isActive) return // already recording (e.g. replay in the tail)
        val temp = java.io.File(dir, "master-rec.wav")
        val startMs = System.currentTimeMillis()
        val cb = onRecordingComplete
        val ok = masterRecorder.begin(temp) { f -> if (f != null) cb?.invoke(f, startMs) }
        if (!ok) return
        recTailRemaining = -1
        recWasPlaying = true
        recCapturing = true // volatile store publishes the two fields above to the audio thread
        onRecordingStarted?.invoke()
    }

    /**
     * Audio thread: feed one master sample to the recorder and run the stop/tail
     * state machine. While the sequencer plays, samples stream straight through. On
     * the play->stop edge we latch a tail countdown (bars -> samples at the current
     * tempo/signature); the recorder keeps capturing the ringing FX/voice tail until
     * it elapses, then finalizes the file. Voices and insert FX render every sample
     * regardless of [playing], so the tail contains real decaying reverb/delay audio.
     */
    private fun recordSample(proj: Project?, left: Float, right: Float) {
        val nowPlaying = playing
        if (recWasPlaying && !nowPlaying) {
            recTailRemaining = if (proj == null) 0
            else (recTailBars * proj.timeSignature.linesPerBar * samplesPerLine(proj)).toInt()
        }
        recWasPlaying = nowPlaying
        masterRecorder.write(left, right)
        if (!nowPlaying) {
            if (recTailRemaining <= 0) {
                recCapturing = false
                masterRecorder.endCapture()
            } else {
                recTailRemaining--
            }
        }
    }

    fun setProject(p: Project) {
        project = p
        rebuildArrangement()
        rebuildFxChains()
    }

    /**
     * Resolve each mixer channel's four FX slots into real effect processors.
     * Call from the main thread (from [play], [setProject], or after the user
     * changes a channel's FX routing). Effect *parameters* update live every
     * block, so only add/remove of an effect needs a rebuild.
     */
    fun rebuildFxChains() {
        val proj = project ?: return
        val next = Array(Pattern.TRACK_COUNT) { emptyArray<FxUnit>() }
        for (ch in 0 until Pattern.TRACK_COUNT) {
            val units = ArrayList<FxUnit>(proj.mixer.channels[ch].fxSlots.size)
            for (slotIndex in proj.mixer.channels[ch].fxSlots) {
                if (slotIndex < 0) continue
                val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
                val type = slot.type ?: continue
                AudioEffect.create(type, sampleRate)?.let { fx ->
                    units.add(FxUnit(slot, fx))
                }
            }
            next[ch] = units.toTypedArray()
        }
        channelChains = next // atomic swap; audio thread sees old or new whole array
    }

    /**
     * Recomputes the arrangement playlist and resets sequencer position. Call
     * from the main thread (never the audio thread) — from [play] and whenever
     * the arrangement or loop settings change.
     */
    fun rebuildArrangement() {
        val proj = project ?: return
        val arr = proj.arrangement
        arrangementMode = arr.slots.any { lane -> lane.any { it != Arrangement.EMPTY } }
        playlist = buildPlaylist(arr)
        playlistIndex = 0
        lineInBeat = 0
        arrPlayLine = 0
    }

    /**
     * Rebuild the playlist in place from the current loop settings WITHOUT resetting
     * the play position (index, cursors, sample counter). Call from the main thread
     * after a live edit that only changes the beat sequence — e.g. a loop-region
     * repeat-count change — so playback keeps rolling instead of restarting.
     *
     * The audio thread reads [playlist] as a single volatile snapshot and bounds-
     * checks its own index, so swapping the array under it is safe: growing the
     * loop appends future repeats seamlessly; shrinking it simply wraps to the loop
     * start at the next beat boundary rather than jumping the transport.
     */
    fun refreshLoopPlaylist() {
        val proj = project ?: return
        if (!arrangementMode) return // not looping the arrangement; nothing live to update
        playlist = buildPlaylist(proj.arrangement)
    }

    /** Expand the arrangement (respecting loop + A/B regions) into a flat beat list. */
    private fun buildPlaylist(arr: Arrangement): IntArray {
        val a = arr.regionA
        val b = arr.regionB
        // With no A/B loop regions in play, walk beats 0..last-filled — never out
        // through the trailing empty beats of the canvas. Whether this stops or
        // repeats is then decided by loopEnabled in advanceArrangement: loop off
        // plays it once and stops after the last filled block; loop on repeats the
        // filled span. Regions only take effect while looping (as before).
        if (!arr.loopEnabled || (!a.isValid && !b.isValid)) {
            val end = (arr.lastFilledBeat() + 1).coerceAtLeast(0)
            return IntArray(end) { it }
        }
        val out = ArrayList<Int>()
        if (a.isValid) repeat(a.repeats) { for (beat in a.start until a.end) out.add(beat) }
        if (b.isValid) repeat(b.repeats) { for (beat in b.start until b.end) out.add(beat) }
        return out.toIntArray()
    }

    // ---------------------------------------------------------------- lifecycle
    private var nativeBridge: NativeAudioBridge? = null

    /** Invoked (off the main thread) if the native Oboe backend can't initialise and
     *  the engine falls back to the AudioTrack loop, with a user-facing message. */
    var onNativeInitError: ((String) -> Unit)? = null

    fun start() {
        if (running) return
        running = true
        // The low-latency native (Oboe) backend is the default. Fall back to the
        // Kotlin AudioTrack loop ONLY if it can't initialise — and report why.
        if (NativeAudioBridge.isAvailable()) {
            val bridge = NativeAudioBridge(this)
            if (bridge.start(sampleRate, BLOCK_FRAMES)) {
                nativeBridge = bridge
                return
            }
            onNativeInitError?.invoke(
                "Low-latency audio (Oboe) failed to start — falling back to standard playback.",
            )
        } else {
            onNativeInitError?.invoke(
                "Low-latency audio (Oboe) is unavailable on this device — using standard playback.",
            )
        }
        startAudioTrack()
    }

    private fun startAudioTrack() {
        val minBuf = AudioTrack.getMinBufferSize(
            sampleRate, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_FLOAT,
        ).coerceAtLeast(BLOCK_FRAMES * 2 * 4 * 2) // stereo float, a couple of blocks

        track = AudioTrack.Builder()
            .setAudioAttributes(
                AudioAttributes.Builder()
                    .setUsage(AudioAttributes.USAGE_MEDIA)
                    .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                    .build(),
            )
            .setAudioFormat(
                AudioFormat.Builder()
                    .setSampleRate(sampleRate)
                    .setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
                    .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
                    .build(),
            )
            .setBufferSizeInBytes(minBuf)
            .setTransferMode(AudioTrack.MODE_STREAM)
            .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
            .build()
            .also { it.play() }

        applyPreferredOutput() // route to the user-chosen USB/audio device, if any

        renderThread = thread(name = "sizzle-audio", priority = Thread.MAX_PRIORITY) {
            audioTrackLoop()
        }
    }

    // ---- USB / audio output device routing ----
    @Volatile private var preferredOutput: android.media.AudioDeviceInfo? = null

    /** Route playback to a specific output device (e.g. a USB DAC), or null for
     *  the system default. Applied immediately and re-applied on engine restart. */
    fun setPreferredOutput(device: android.media.AudioDeviceInfo?) {
        preferredOutput = device
        applyPreferredOutput()
    }

    private fun applyPreferredOutput() {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
            track?.setPreferredDevice(preferredOutput)
        }
    }

    /** Stop the engine entirely (called from the PlaybackService on shutdown). */
    fun release() {
        running = false
        // Finalize any in-flight recording so the WAV header gets patched and the
        // completion callback fires instead of leaving a truncated temp file.
        if (recCapturing) { recCapturing = false; masterRecorder.endCapture() }
        nativeBridge?.stop()
        nativeBridge = null
        renderThread?.join(500)
        renderThread = null
        track?.run { stop(); release() }
        track = null
    }

    // --------------------------------------------------------------- transport
    fun play() {
        rebuildArrangement()
        rebuildFxChains()
        pendingTrigger = true
        playing = true
        maybeStartRecording()
        publish()
    }

    fun pause() { playing = false; allSeqVoicesOff(); publish() }

    /**
     * MIDI panic: immediately hard-silence every voice — sequencer, live (keyboard/
     * MIDI/gamepad), and audition — and cancel all arpeggiators. Clears stuck or
     * hung notes at once (e.g. a MIDI note-on whose note-off never arrived). Does
     * not stop the transport; if the sequencer is playing it resumes on the next
     * tick.
     */
    fun panic() {
        seqVoices.forEach { it.kill() }
        sampleVoices.forEach { it.kill() }
        liveVoices.forEach { it.kill() }
        auditionSampleVoices.forEach { it.kill() }
        busVoices.forEach { it.kill() }
        busSampleVoices.forEach { it.kill() }
        arps.forEach { it.stop() }
        trackLastChannel.fill(-1)
    }

    /** Stop playback. If already stopped, rewind to the very beginning. */
    fun stop() {
        if (!playing && currentLine == 0 && playlistIndex == 0 && lineInBeat == 0) return
        val wasStopped = !playing
        playing = false
        allSeqVoicesOff()
        if (wasStopped) {
            currentLine = 0; samplesIntoLine = 0.0
            playlistIndex = 0; lineInBeat = 0
            arrPlayLine = 0
        }
        publish()
    }

    // ------------------------------------------------------ live note audition
    fun liveNoteOn(pitch: Int, velocity: Int) {
        val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
        v.noteOn(pitch, velocity, SynthPatch.DEFAULT)
    }

    fun liveNoteOff(pitch: Int) {
        liveVoices.firstOrNull { it.isActive && it.pitch == pitch }?.noteOff()
    }

    // ------------------------------------------------------------- render loop
    /** The Kotlin AudioTrack fallback loop: fill a block and push it. */
    private fun audioTrackLoop() {
        val block = FloatArray(BLOCK_FRAMES * 2) // interleaved stereo (L,R per frame)
        while (running) {
            val t = track ?: break
            fillBlock(block, BLOCK_FRAMES)
            t.write(block, 0, BLOCK_FRAMES * 2, AudioTrack.WRITE_BLOCKING)
        }
    }

    /**
     * Render [frames] frames of the whole mix into [out] as INTERLEAVED STEREO —
     * out[2*i] = left, out[2*i+1] = right, so [out] must hold at least frames*2
     * samples. This is the single source of truth for audio: it is called by the
     * Kotlin AudioTrack loop AND by the native Oboe callback (via [NativeAudioBridge]),
     * so both backends produce identical sound. Runs on an audio thread — must not
     * allocate. Voices and inserts are mono, so both master channels carry the same
     * sum; the output/recording path is nonetheless stereo (2 interleaved channels).
     */
    fun fillBlock(out: FloatArray, frames: Int) {
        val proj = project
        // Per-block param housekeeping: LFOs modulate targets first, then effects
        // read their (possibly modulated) parameters.
        if (proj != null) {
            applyLfos(proj)
            updateFxParams(proj)
            refreshSynthPatches(proj)
            // Tempo/signature only change at control rate, so derive the per-line
            // sample count once per block instead of re-dividing every sample.
            blockSamplesPerLine = samplesPerLine(proj)
        }
        val anySolo = proj?.mixer?.anySolo() ?: false
        // Snapshot the volatile chains once so a concurrent rebuild can't swap them
        // mid-block; indexing + array iteration below then allocate nothing.
        val chains = channelChains

        // Once per block: collect the indices of the voices that are actually sounding
        // into compact lists, so the per-sample loop iterates only those instead of the
        // whole pool. Building the lists costs O(pool) once; rendering then costs
        // O(active * frames) rather than O(pool * frames). The sequencer appends any
        // note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
        blockGen++
        actSeqN = 0; actSmpN = 0; actBusN = 0; actBSmpN = 0; actLiveN = 0; actASmpN = 0
        for (i in seqVoices.indices) if (seqVoices[i].isActive) { seqGen[i] = blockGen; actSeq[actSeqN++] = i }
        for (i in sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
        for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
        for (i in busSampleVoices.indices) if (busSampleVoices[i].isActive) actBSmp[actBSmpN++] = i
        for (i in liveVoices.indices) if (liveVoices[i].isActive) actLive[actLiveN++] = i
        for (i in auditionSampleVoices.indices) if (auditionSampleVoices[i].isActive) actASmp[actASmpN++] = i

        for (i in 0 until frames) {
            if (playing && proj != null) advanceSequencer(proj)

            var mixL = 0f
            var mixR = 0f
            if (proj != null) {
                channelSum.fill(0f)
                // Sum only the voices that are actually sounding this block. Each voice's
                // bus is its index / pool stride. Live bus-audition voices feed the same
                // per-channel sum, so they run through the channel's FX/volume/mute like
                // the sequencer.
                for (k in 0 until actSeqN) { val vi = actSeq[k]; channelSum[vi / POLYPHONY] += seqVoices[vi].render() }
                for (k in 0 until actSmpN) { val vi = actSmp[k]; channelSum[vi / POLYPHONY] += sampleVoices[vi].render() }
                for (k in 0 until actBusN) { val vi = actBus[k]; channelSum[vi / BUS_POLYPHONY] += busVoices[vi].render() }
                for (k in 0 until actBSmpN) { val vi = actBSmp[k]; channelSum[vi / BUS_POLYPHONY] += busSampleVoices[vi].render() }
                for (ch in 0 until Pattern.TRACK_COUNT) {
                    val mc = proj.mixer.channels[ch]
                    var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
                    val chain = chains[ch]
                    for (u in chain) s = u.effect.process(s)
                    // Isolate a misbehaving effect: a non-finite sample from one
                    // channel's FX must not poison the whole master sum.
                    if (!s.isFinite()) s = 0f
                    // Optional per-bus soft-clip limiter (toolbar toggle).
                    if (mc.limiter) s = softClip(s)
                    // Voices and inserts are mono; both master channels get the same
                    // sum. The master output/recording path stays stereo (2 channels).
                    mixL += s; mixR += s
                }
            }
            for (k in 0 until actLiveN) { val v = liveVoices[actLive[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
            for (k in 0 until actASmpN) { val v = auditionSampleVoices[actASmp[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
            val oL = softClip(mixL * MASTER_GAIN)
            val oR = softClip(mixR * MASTER_GAIN)
            out[2 * i] = oL
            out[2 * i + 1] = oR
            if (recCapturing) recordSample(proj, oL, oR)
        }
    }

    /** Push slot parameters (+ tempo) into every live effect processor, but only when
     *  they actually changed — re-parsing unchanged params every block allocates and
     *  caused GC-pause dropouts when several effects were stacked. */
    private fun updateFxParams(proj: Project) {
        val tempo = proj.tempoBpm
        val chains = channelChains
        for (ch in 0 until Pattern.TRACK_COUNT) {
            val chain = chains[ch]
            for (u in chain) {
                val v = u.slot.version
                if (v != u.lastVersion || tempo != u.lastTempo) {
                    u.effect.update(u.slot, tempo)
                    u.lastVersion = v
                    u.lastTempo = tempo
                }
            }
        }
    }

    // Lock-free xorshift RNG for the audio thread. Math.random() is backed by a
    // single synchronized java.util.Random, so calling it on the real-time thread
    // risks lock contention / priority inversion; this never blocks or allocates.
    private var rngState = 0x9E3779B9.toInt()
    /** Next pseudo-random float in [0,1). */
    private fun nextRandomUnit(): Float {
        var s = rngState
        s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5)
        rngState = s
        return (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f
    }

    // ---- MIDI LFO modulation ----
    private val lfoPhase = DoubleArray(Project.TOOLBOX_SLOTS)
    private val lfoSampleHold = DoubleArray(Project.TOOLBOX_SLOTS) { Math.random() * 2 - 1 }

    /**
     * Apply every LFO slot's modulation to its target parameter, once per block.
     * The target is stored as "slotIndex:paramKey"; the LFO oscillates the value
     * around a captured centre by ±depth·(half the param's range). The LFO thus
     * "takes over" its target parameter while active.
     */
    private fun applyLfos(proj: Project) {
        val secondsPerBeat = 60.0 / proj.tempoBpm
        val blockSeconds = BLOCK_FRAMES.toDouble() / sampleRate
        for (i in proj.toolbox.indices) {
            val lfo = proj.toolbox[i]
            if (lfo.type != ToolboxType.LFO) continue
            val target = lfo.string("target")
            val sep = target.indexOf(':')
            if (sep <= 0) continue
            // Parse the target slot index from target[0 until sep] without a substring
            // or a boxed Int? — this runs every block for each active LFO.
            var tIdx = 0
            var validIdx = true
            for (ci in 0 until sep) {
                val ch = target[ci]
                if (ch < '0' || ch > '9') { validIdx = false; break }
                tIdx = tIdx * 10 + (ch - '0')
            }
            if (!validIdx || tIdx == i) continue
            val tSlot = proj.toolbox.getOrNull(tIdx) ?: continue
            val params = tSlot.type?.params ?: continue
            val key = target.substring(sep + 1)
            // Indexed scan (not firstOrNull {}) so no Iterator allocates per block.
            var specIdx = -1
            for (pi in params.indices) if (params[pi].key == key) { specIdx = pi; break }
            if (specIdx < 0) continue
            val spec = params[specIdx]

            val div = TimeDivision.fromIndex(lfo.float("division", 3f).toInt())
            val freq = 1.0 / (div.beatFraction * secondsPerBeat).coerceAtLeast(1e-6)
            val advanced = lfoPhase[i] + freq * blockSeconds
            if (advanced >= 1.0) lfoSampleHold[i] = nextRandomUnit() * 2.0 - 1.0
            lfoPhase[i] = advanced % 1.0
            val phase = (lfoPhase[i] + lfo.float("phase", 0f)) % 1.0

            val value = when (lfo.string("wave", "Sine")) {
                "Triangle" -> 1.0 - 4.0 * abs(phase - 0.5)
                "Saw" -> 2.0 * phase - 1.0
                "Square" -> if (phase < 0.5) 1.0 else -1.0
                "SampleHold" -> lfoSampleHold[i]
                else -> sin(2 * Math.PI * phase)
            }
            val center = lfo.float("center", spec.default).toDouble()
            val half = (spec.max - spec.min) / 2.0
            val modulated = (center + lfo.float("depth", 0.5f) * value * half)
                .coerceIn(spec.min.toDouble(), spec.max.toDouble())
            tSlot.set(key, modulated.toFloat())
        }
    }

    /** Dispatch one sample of sequencer time to the active playback mode. */
    private fun advanceSequencer(proj: Project) {
        if (arrangementMode) advanceArrangement(proj) else advancePatternLoop(proj)
        advanceArps(proj) // arpeggiators retrigger between note events
    }

    // ----- Pattern-loop mode (Tracker tab audition) -----
    private fun advancePatternLoop(proj: Project) {
        val pattern = proj.activePattern()
        val samplesPerLine = blockSamplesPerLine

        if (pendingTrigger) {
            val linesPerBar = proj.timeSignature.linesPerBar
            if (linesPerBar > 0 && currentLine % linesPerBar == 0) resetBarArps()
            triggerPatternLine(proj, pattern, currentLine)
            pendingTrigger = false
        }
        samplesIntoLine += 1.0
        if (samplesIntoLine >= samplesPerLine) {
            samplesIntoLine -= samplesPerLine
            currentLine++
            if (currentLine >= pattern.length) currentLine = 0
            pendingTrigger = true
            publish()
        }
    }

    private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
        // Pattern-loop plays on lane 0's voices; each cell is routed by its channel.
        // Apply note-offs FIRST, then note-ons, so a note-off on one track never
        // cancels a note that begins on the same line/bus (a note-off releases the
        // whole bus; without this ordering it would kill a just-triggered voice).
        for (t in 0 until Pattern.TRACK_COUNT) {
            val cell = pattern.cell(t, line)
            if (cell.isNoteOff) triggerCell(proj, lane = 0, track = t, cell = cell)
        }
        for (t in 0 until Pattern.TRACK_COUNT) {
            val cell = pattern.cell(t, line)
            if (cell.isPlayable) triggerCell(proj, lane = 0, track = t, cell = cell)
        }
    }

    /**
     * Trigger one tracker [cell] from source [track] on [lane].
     *
     * A PLAYABLE note is routed to the mixer bus(es) whose MIDI channel matches the
     * cell's channel — NOT the source track — so a note plays whatever instrument
     * sits on its channel's bus, wherever it's placed in the grid (this decouples
     * tracks from buses). Each matching bus applies its own MIDI effects (Transposer
     * shifts pitch; an Arpeggiator captures the note, retriggered by [advanceArps]).
     * The channel used is remembered per (lane, track).
     *
     * A NOTE-OFF is tied to its TRACK, not to a channel: it has no channel/velocity
     * of its own and simply releases whatever this track last started — on the same
     * bus(es) that track's previous note routed to ([trackLastChannel]).
     */
    private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
        val trackKey = lane * Pattern.TRACK_COUNT + track
        if (cell.isNoteOff) {
            val ch = trackLastChannel[trackKey]
            if (ch < 0) return // this track hasn't played anything yet
            for (bus in 0 until Pattern.TRACK_COUNT) {
                if (proj.mixer.channels[bus].midiChannel != ch) continue
                arps[lane * Pattern.TRACK_COUNT + bus].stop()
                releaseBus(bus)
            }
            return
        }
        if (!cell.isPlayable) return
        // Remember the channel so a later note-off on this track releases it.
        trackLastChannel[trackKey] = cell.channel
        for (bus in 0 until Pattern.TRACK_COUNT) {
            if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
            val arp = arps[lane * Pattern.TRACK_COUNT + bus]
            val velocity = cell.velocity.coerceIn(0, 127)
            val note = (cell.note + channelTranspose(proj, bus)).coerceIn(0, 127)
            val arpSlot = channelArpSlot(proj, bus)
            if (arpSlot != null) {
                val octaves = arpSlot.float("octaves", 1f).toInt()
                val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
                val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
                val mode = arpSlot.string("mode", "Up")
                val resetBar = arpSlot.string("resetbar", "Off") == "On"
                if (arp.active && arp.base == note) {
                    // The same note is already arpeggiating — this trigger is the
                    // pattern looping back over a still-held note. Keep the running
                    // phase (just refresh params) so the pulse stays steady across
                    // the loop point instead of slipping when the loop length isn't
                    // an exact multiple of the arp step (dotted/triplet divisions).
                    arp.refresh(octaves, mode, stepSamples, resetBar)
                } else {
                    arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
                    // The arpeggiator is monophonic (it cycles octaves of ONE note),
                    // so it must not stack notes. Releasing the bus first means a
                    // chord entered on the same line collapses to a single
                    // arpeggiated note (the last one), not all of them at once.
                    releaseBus(bus)
                    playNote(proj, bus, arp.currentNote(), velocity)
                }
            } else {
                arp.stop()
                playNote(proj, bus, note, velocity)
            }
        }
    }

    /**
     * Sound a note on a mixer [bus]: allocate a [SampleVoice] from the bus pool when
     * the channel's instrument is a Sampler/SoundFont with a loaded sample, otherwise
     * a [SynthVoice] for the NES synth. Pooled allocation (up to [POLYPHONY] per bus,
     * stealing the oldest when full) is what makes the instrument polyphonic. Shared
     * by the sequencer and the arpeggiator.
     */
    private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int) {
        // The allocators ([allocSynthVoice]/[allocSampleVoice]) queue the chosen voice
        // into this block's active list, so a note started mid-block sounds sample-
        // accurately within the same block.
        val slot = proj.toolbox.getOrNull(proj.mixer.channels[bus].instrumentSlot)
        val vs = voiceSample(slot, note)
        when {
            vs != null ->
                allocSampleVoice(bus).noteOn(vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
                    vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs)
            // Empty bus (no instrument — e.g. the default channel 0) or a Sampler
            // with an empty pad → silent, matching the live audition path.
            slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
            // NES synth, or a SoundFont with nothing loaded → the tone generator.
            else -> {
                allocSynthVoice(bus).noteOn(note, velocity, synthPatch[bus] ?: patchForSlot(slot, proj.tempoBpm))
            }
        }
    }

    /** A sample voice's source + pitch + amplitude-envelope info for a note. */
    private class VoiceSample(
        val sample: SampleStore.Sample, val root: Int,
        val sliceStart: Float, val sliceEnd: Float, val volume: Float,
        val attack: Float = 0.002f, val decay: Float = 0f,
        val sustain: Float = 1f, val release: Float = 0.02f,
        val loop: Boolean = false, val crossfadeMs: Float = 0f,
    )

    /**
     * Resolve the PCM sample a note should play for [slot], or null if the slot
     * isn't a loaded sample instrument (the caller then uses the synth).
     *  - Sampler: the pad [note] maps to (root == note, so it plays unpitched). Pads
     *    stay one-shot (default envelope), just carrying their per-pad volume/slice.
     *  - SoundFont: the loaded SF2/XI sample, pitched from its stored root note, with
     *    the user's volume ADSR envelope applied.
     */
    private fun voiceSample(slot: ToolboxSlot?, note: Int): VoiceSample? = when (slot?.type) {
        ToolboxType.SAMPLER -> {
            val pad = SamplerPads.padFor(note)
            if (pad !in 0 until SamplerPads.COUNT) {
                null
            } else {
                SampleStore.get(slot.string(SamplerPads.key(pad)))?.let {
                    VoiceSample(
                        it, note,
                        slot.float(SamplerPads.sliceStartKey(pad), 0f),
                        slot.float(SamplerPads.sliceEndKey(pad), 1f),
                        slot.float(SamplerPads.volumeKey(pad), SamplerPads.DEFAULT_VOLUME),
                    )
                }
            }
        }
        ToolboxType.SOUNDFONT ->
            SampleStore.get(slot.string("sfSample"))?.let {
                VoiceSample(
                    it, slot.float("sfRoot", 60f).toInt(), 0f, 1f, slot.float("volume", 0.8f),
                    attack = slot.float("attack", 0.005f),
                    decay = slot.float("decay", 0.10f),
                    sustain = slot.float("sustain", 1f),
                    release = slot.float("release", 0.20f),
                    loop = slot.string("loop", "Off") == "On",
                    crossfadeMs = slot.float("loopXfade", 0f),
                )
            }
        else -> null
    }

    /** Realign every active "reset on bar" arpeggiator to step 0 — called on each
     *  bar downbeat, before that line's own note triggers (so a fresh note-on on the
     *  downbeat isn't double-fired). */
    private fun resetBarArps() {
        for (i in arps.indices) {
            val a = arps[i]
            if (a.active && a.resetOnBar) a.resetToBar()
        }
    }

    /** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
    private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
        for (idx in proj.mixer.channels[track].fxSlots) {
            if (idx < 0) continue
            val slot = proj.toolbox.getOrNull(idx) ?: continue
            if (slot.type == ToolboxType.ARPEGGIATOR) return slot
        }
        return null
    }

    /** Advance every active arpeggiator by one sample; retrigger on step boundaries. */
    private fun advanceArps(proj: Project) {
        for (i in arps.indices) {
            val a = arps[i]
            if (!a.active) continue
            a.samplesLeft -= 1.0
            if (a.samplesLeft <= 0.0) {
                // A pending bar reset plays step 0 as-is; otherwise advance the pattern.
                if (!a.consumePendingReset()) a.advance()
                val bus = i % Pattern.TRACK_COUNT
                // Release the previous step before sounding the next one. Without this
                // an arp on a *sustaining* instrument (e.g. an SF2/XI with sustain=1
                // and a long sample) stacks a fresh full-level voice every step until
                // the whole 8-voice pool is held wide open — a sustained wall of sound
                // that overloads any following FX (a reverb's make-up gain then slams
                // the limiter). Releasing keeps the arp monophonic with natural tails.
                releaseBus(bus)
                playNote(proj, bus, a.currentNote(), a.velocity)
                a.samplesLeft += a.stepSamples
            }
        }
    }

    /** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */
    private class ChannelArp {
        var active = false; var base = -1; var velocity = 100
        private var octaves = 1; private var mode = 0
        var stepSamples = 0.0; var samplesLeft = 0.0
        private var step = 0; private var dir = 1
        /** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */
        var resetOnBar = false
        // Set by [resetToBar] so the next fire plays step 0 without first advancing.
        private var pendingReset = false
        // Per-arp lock-free RNG (Random mode) — no Math.random() on the audio thread.
        private var rng = 0x2545F491.toInt()
        private fun rnd(bound: Int): Int {
            var s = rng
            s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5)
            rng = s
            return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt()
        }

        fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
            active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
            mode = modeOf(modeStr)
            stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
            resetOnBar = resetBar; pendingReset = false
        }

        /** Update params WITHOUT resetting the running phase — used when the pattern
         *  loops over a still-arpeggiating note so the steady pulse continues
         *  seamlessly across the loop boundary. */
        fun refresh(octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
            octaves = octs.coerceIn(1, 4)
            mode = modeOf(modeStr)
            stepSamples = stepSamp.coerceAtLeast(1.0)
            resetOnBar = resetBar
        }

        /** Realign the pattern to step 0 at a bar downbeat: the next [advanceArps]
         *  fire (scheduled immediately) plays step 0 without advancing first. */
        fun resetToBar() { step = 0; dir = 1; samplesLeft = 0.0; pendingReset = true }

        /** True (once) if a bar reset is pending, so the caller skips [advance]. */
        fun consumePendingReset(): Boolean { val p = pendingReset; pendingReset = false; return p }

        private fun modeOf(modeStr: String): Int =
            when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }

        fun stop() { active = false; base = -1 }
        fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)

        fun advance() {
            when (mode) {
                1 -> step = (step - 1 + octaves) % octaves
                2 -> if (octaves > 1) {
                    step += dir
                    if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
                    else if (step <= 0) { step = 0; dir = 1 }
                }
                3 -> step = if (octaves > 1) rnd(octaves) else 0
                else -> step = (step + 1) % octaves
            }
        }
    }

    /** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
    private fun channelTranspose(proj: Project, track: Int): Int {
        var semis = 0
        for (idx in proj.mixer.channels[track].fxSlots) {
            if (idx < 0) continue
            val slot = proj.toolbox.getOrNull(idx) ?: continue
            if (slot.type == ToolboxType.TRANSPOSER) {
                semis += slot.float("semitones", 0f).toInt() + slot.float("octaves", 0f).toInt() * 12
            }
        }
        return semis
    }

    /** Start auditioning a note on the instrument in toolbox [slotIndex] (used by
     *  the Toolbox test keyboard and the Sampler editor). Polyphonic. */
    fun auditionSlotOn(slotIndex: Int, midi: Int) {
        val proj = project ?: return
        val slot = proj.toolbox.getOrNull(slotIndex)
        val vs = voiceSample(slot, midi)
        if (vs != null) {
            val v = auditionSampleVoices.firstOrNull { !it.isActive } ?: auditionSampleVoices[0].also { it.kill() }
            v.noteOn(vs.sample, midi, 110, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
                vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs)
            return
        }
        // A Sampler with an empty pad stays silent (audition is a one-shot with no
        // note-off, so a sustaining synth voice would ring forever). A SoundFont
        // with nothing loaded still falls through to the synth preview below.
        if (slot?.type == ToolboxType.SAMPLER) return
        // Remember which slot is being auditioned so refreshSynthPatches pushes live
        // knob turns into the held preview note.
        if (slot?.type == ToolboxType.NES_SYNTH) auditionSlotIdx = slotIndex
        val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
        v.noteOn(midi, 110, patchForSlot(slot, project?.tempoBpm ?: 120f))
    }

    /** Release an auditioned note (matches both synth and sample audition pools). */
    fun auditionSlotOff(midi: Int) {
        liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
        auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
    }

    // ---------------------------------------------- live mix-bus audition (MIDI piano)
    /**
     * Play [midi] on every mixer channel listening on [midiChannel], sounding each
     * channel's instrument through its full insert chain, volume and mute/solo —
     * i.e. auditioning the actual mix bus, not a raw toolbox slot.
     */
    fun busNoteOn(midiChannel: Int, midi: Int, velocity: Int) {
        val proj = project ?: return
        for (ch in 0 until Pattern.TRACK_COUNT) {
            if (proj.mixer.channels[ch].midiChannel != midiChannel) continue
            val slot = proj.toolbox.getOrNull(proj.mixer.channels[ch].instrumentSlot)
            // Empty bus (no instrument assigned) → silent. Auditioning a bus should
            // only sound when it actually has an instrument, never a fallback synth.
            val type = slot?.type ?: continue
            val base = ch * BUS_POLYPHONY
            val vs = voiceSample(slot, midi)
            when {
                vs != null -> {
                    val v = (0 until BUS_POLYPHONY).map { busSampleVoices[base + it] }
                        .firstOrNull { !it.isActive } ?: busSampleVoices[base].also { it.kill() }
                    v.noteOn(vs.sample, midi, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
                        vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs)
                }
                // Sampler with an empty pad → silent (no synth fallback).
                type == ToolboxType.SAMPLER -> {}
                else -> {
                    val v = (0 until BUS_POLYPHONY).map { busVoices[base + it] }
                        .firstOrNull { !it.isActive } ?: busVoices[base].also { it.kill() }
                    v.noteOn(midi, velocity, synthPatch[ch] ?: patchForSlot(slot, proj.tempoBpm))
                }
            }
        }
    }

    /** Release [midi] on every mixer channel listening on [midiChannel]. */
    fun busNoteOff(midiChannel: Int, midi: Int) {
        val proj = project ?: return
        for (ch in 0 until Pattern.TRACK_COUNT) {
            if (proj.mixer.channels[ch].midiChannel != midiChannel) continue
            val base = ch * BUS_POLYPHONY
            for (i in 0 until BUS_POLYPHONY) {
                busVoices[base + i].takeIf { it.isActive && it.pitch == midi }?.noteOff()
                busSampleVoices[base + i].takeIf { it.isActive && it.pitch == midi }?.noteOff()
            }
        }
    }

    // ----- Arrangement mode -----
    private fun advanceArrangement(proj: Project) {
        val arr = proj.arrangement
        // Snapshot the volatile playlist once so a concurrent live rebuild (from
        // refreshLoopPlaylist) can't change its size mid-computation.
        val list = playlist
        if (list.isEmpty()) { playing = false; publish(); return }
        val linesPerBeat = proj.timeSignature.linesPerBeat
        val samplesPerLine = blockSamplesPerLine
        val beat = list[playlistIndex.coerceIn(0, list.size - 1)]

        if (pendingTrigger) {
            // Bar downbeat = first line of a beat that starts a bar (in arrangement
            // beats). Reset "reset on bar" arps before this line's own triggers.
            val beatsPerBar = proj.timeSignature.beatsPerBar
            if (lineInBeat == 0 && beatsPerBar > 0 && beat % beatsPerBar == 0) resetBarArps()
            triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
            pendingTrigger = false
        }

        samplesIntoLine += 1.0
        if (samplesIntoLine >= samplesPerLine) {
            samplesIntoLine -= samplesPerLine
            lineInBeat++
            if (lineInBeat >= linesPerBeat) {
                lineInBeat = 0
                playlistIndex++
                if (playlistIndex >= list.size) {
                    if (arr.loopEnabled) {
                        playlistIndex = 0
                    } else {
                        playing = false; allSeqVoicesOff(); publish(); return
                    }
                }
            }
            pendingTrigger = true
            publish()
        }
    }

    /**
     * Play one tick of every block marked at [beat]. Mirrors the reference
     * sizzletracker `playAt`: a marker means the block sounds *that beat*, and a
     * contiguous run of markers plays the block from its start beat-by-beat (and
     * restarts after any erased gap). The beat-within-block is derived STATELESSLY
     * from the run's start each tick — no mutable cursor — so it stays correct
     * across loop wraps and region loops (the earlier "only the first beat plays
     * on loop" bug came from a cursor that reset every loop cycle).
     */
    private fun triggerArrangementLine(
        proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
    ) {
        // Two passes across all lanes: note-offs first, then note-ons, so a note-off
        // never cancels a note that begins on the same line/bus (a note-off releases
        // the whole bus). See [triggerPatternLine].
        for (pass in 0..1) {
            for (lane in 0 until Arrangement.LANE_COUNT) {
                if (arr.laneMuted[lane]) continue // muted MIDI lane — skip its blocks
                val p = arr.patternAt(lane, beat)
                if (p == Arrangement.EMPTY) continue
                val pat = proj.pattern(p) ?: continue
                // Walk back to the start of this block's contiguous run of markers so it
                // plays from beat 0 wherever the run begins.
                var runStart = beat
                while (runStart > 0 && arr.patternAt(lane, runStart - 1) != Arrangement.EMPTY) runStart--
                val blockBeats = (pat.length / linesPerBeat).coerceAtLeast(1)
                val patLine = ((beat - runStart) % blockBeats) * linesPerBeat + lineInBeat
                // The edited block drives the tracker playhead (set once, in pass 0).
                if (pass == 0 && p == proj.activePatternId) arrPlayLine = patLine.coerceIn(0, pat.length - 1)
                if (patLine >= pat.length) continue
                for (t in 0 until Pattern.TRACK_COUNT) {
                    val cell = pat.cell(t, patLine)
                    if (pass == 0) { if (cell.isNoteOff) triggerCell(proj, lane, t, cell) }
                    else { if (cell.isPlayable) triggerCell(proj, lane, t, cell) }
                }
            }
        }
    }

    /** Parse a slot's synth patch (default init patch if it isn't an NES synth).
     *  Allocates one [SynthPatch] — used only for the first audition note; the
     *  sequencer/bus paths use the per-block cache in [refreshSynthPatches]. */
    private fun patchForSlot(slot: ToolboxSlot?, tempoBpm: Float): SynthPatch =
        if (slot?.type == ToolboxType.NES_SYNTH) SynthPatch.from(slot, tempoBpm) else SynthPatch.DEFAULT

    /**
     * Once per block: refresh each bus's cached synth patch (reparsing only when the
     * slot version or tempo changed) and push it into that bus's *active* synth voices
     * plus the editor's audition voices. This is what makes filter/level/mod knob
     * turns audible on already-sounding notes, and keeps note triggers allocation-free.
     * Runs on the audio thread; slot reads are safe against UI edits because the param
     * map is concurrent.
     */
    private fun refreshSynthPatches(proj: Project) {
        val tempo = proj.tempoBpm
        for (ch in 0 until Pattern.TRACK_COUNT) {
            val slot = proj.toolbox.getOrNull(proj.mixer.channels[ch].instrumentSlot)
            if (slot?.type != ToolboxType.NES_SYNTH) { synthPatch[ch] = null; synthPatchVer[ch] = -1; continue }
            if (synthPatch[ch] == null || slot.version != synthPatchVer[ch] || tempo != synthPatchTempo[ch]) {
                synthPatch[ch] = SynthPatch.from(slot, tempo)
                synthPatchVer[ch] = slot.version
                synthPatchTempo[ch] = tempo
            }
            val p = synthPatch[ch] ?: continue
            val sbase = ch * POLYPHONY
            for (i in 0 until POLYPHONY) seqVoices[sbase + i].let { if (it.isActive) it.setPatch(p) }
            val bbase = ch * BUS_POLYPHONY
            for (i in 0 until BUS_POLYPHONY) busVoices[bbase + i].let { if (it.isActive) it.setPatch(p) }
        }
        // Editor audition voices follow the slot currently being previewed.
        val aslot = proj.toolbox.getOrNull(auditionSlotIdx)
        if (aslot?.type == ToolboxType.NES_SYNTH) {
            if (auditionPatch == null || aslot.version != auditionPatchVer || tempo != auditionPatchTempo) {
                auditionPatch = SynthPatch.from(aslot, tempo)
                auditionPatchVer = aslot.version
                auditionPatchTempo = tempo
            }
            auditionPatch?.let { p -> for (v in liveVoices) if (v.isActive) v.setPatch(p) }
        }
    }

    private fun samplesPerLine(proj: Project): Double =
        sampleRate * (60.0 / proj.tempoBpm) / proj.timeSignature.linesPerBeat

    private fun allSeqVoicesOff() {
        seqVoices.forEach { it.noteOff() }
        sampleVoices.forEach { it.noteOff() }
        arps.forEach { it.stop() }
    }

    private fun publish() {
        val proj = project
        val linesPerBeat = proj?.timeSignature?.linesPerBeat ?: 4
        if (arrangementMode) {
            val list = playlist
            val beat = if (list.isNotEmpty()) list[playlistIndex.coerceIn(0, list.size - 1)] else 0
            _transport.value = TransportState(
                isPlaying = playing,
                currentLine = arrPlayLine,
                currentBeat = beat,
            )
        } else {
            _transport.value = TransportState(
                isPlaying = playing,
                currentLine = currentLine,
                currentBeat = currentLine / linesPerBeat,
            )
        }
    }

    companion object {
        private const val BLOCK_FRAMES = 192        // ~4 ms blocks at 48 kHz
        private const val POLYPHONY = 8             // max simultaneous voices per instrument (mixer bus)
        private const val LIVE_POLYPHONY = 8
        private const val AUDITION_POLYPHONY = 8
        private const val BUS_POLYPHONY = 8         // per-mixer-channel live audition
        private const val MASTER_GAIN = 0.35f
        private const val LIVE_GAIN = 0.5f
        private const val SOFT_KNEE = 0.6f          // limiter is transparent below this, saturates above

        /**
         * Continuous soft-knee limiter: linear (transparent) up to [SOFT_KNEE], then
         * smoothly saturating to +-1 above it. The previous clipper was discontinuous
         * at |x|=1 (it snapped the output 1.0 -> 0.5 as the signal crossed full scale),
         * which produced audible clicks whenever a hot bus pushed the mix past 1.0.
         * This version is value- and slope-continuous, so no crossing can click.
         */
        private fun softClip(x: Float): Float {
            val a = if (x < 0f) -x else x
            if (a <= SOFT_KNEE) return x
            val comp = SOFT_KNEE + (1f - SOFT_KNEE) * tanh((a - SOFT_KNEE) / (1f - SOFT_KNEE))
            return if (x < 0f) -comp else comp
        }
    }
}
