{"audio_adapter.lua":"-- audio_adapter.lua\n-- Platform-agnostic audio abstraction layer\n--\n-- This module provides a unified interface for audio across different platforms.\n-- Platform-specific implementations should conform to this interface.\n\nlocal AudioAdapter = {}\n\nlocal function createNullSynth()\n    local function noop()\n    end\n\n    return {\n        setVolume = noop,\n        setADSR = noop,\n        playNote = noop,\n        playLoop = noop,\n        stop = noop\n    }\nend\n\n--[[\n    Creates a new audio adapter instance\n\n    @param implementation table - Platform-specific implementation with the following methods:\n        - init() - Initialize audio system\n        - getVolume() - Get master volume\n        - setVolume(volume) - Set master volume\n        - synthNew(...) - Create a new synth instance\n        - sampleNew(...) - Create a new sample buffer\n        - micStartListening() - Start microphone input\n        - micRecordToSample(buffer, callback) - Record microphone input to a sample\n        - micStopListening() - Stop microphone input\n        - micStopRecording() - Stop recording\n        - micListInputDevices() - List available microphone input devices\n        - micSetInputDevice(index) - Select microphone input device\n\n    @return table - Audio adapter instance\n]]\nfunction AudioAdapter.new(implementation)\n    local self = {\n        impl = implementation\n    }\n\n    -- Initialize the platform-specific implementation\n    if self.impl.init then\n        self.impl.init()\n    end\n\n    -- Wave constants\n    self.kWaveSine = self.impl.kWaveSine\n    self.kWaveTriangle = self.impl.kWaveTriangle\n    self.kWaveSquare = self.impl.kWaveSquare\n    self.kWaveSawtooth = self.impl.kWaveSawtooth\n    self.kWaveWarm = self.impl.kWaveWarm or self.impl.kWaveTriangle or self.impl.kWaveSine\n    self.kWaveVocal = self.impl.kWaveVocal or self.kWaveWarm\n    self.kWaveFlute = self.impl.kWaveFlute or self.impl.kWaveSine or self.kWaveVocal\n\n    --[[\n        Get the master volume\n        @return number - Current volume (0-1)\n    ]]\n    function self.getVolume()\n        if self.impl.getVolume then\n            return self.impl.getVolume()\n        end\n        return 1\n    end\n\n    --[[\n        Set the master volume\n        @param volume number - Volume level (0-1)\n    ]]\n    function self.setVolume(volume)\n        if self.impl.setVolume then\n            self.impl.setVolume(volume)\n        end\n    end\n\n    -- Synth factory\n    self.synth = {\n        new = function(...)\n            if self.impl.synthNew then\n                local synth = self.impl.synthNew(...)\n                if synth then\n                    return synth\n                end\n            end\n            return createNullSynth()\n        end\n    }\n\n    -- Sample factory\n    self.sample = {\n        new = function(...)\n            if self.impl.sampleNew then\n                return self.impl.sampleNew(...)\n            end\n            return nil\n        end\n    }\n\n    -- Microphone input controls\n    self.micinput = {\n        startListening = function(...)\n            if self.impl.micStartListening then\n                return self.impl.micStartListening(...)\n            end\n            return false\n        end,\n        recordToSample = function(...)\n            if self.impl.micRecordToSample then\n                return self.impl.micRecordToSample(...)\n            end\n            return false\n        end,\n        stopListening = function(...)\n            if self.impl.micStopListening then\n                return self.impl.micStopListening(...)\n            end\n        end,\n        stopRecording = function(...)\n            if self.impl.micStopRecording then\n                return self.impl.micStopRecording(...)\n            end\n        end,\n        listInputDevices = function(...)\n            if self.impl.micListInputDevices then\n                return self.impl.micListInputDevices(...)\n            end\n            return {}\n        end,\n        setInputDevice = function(...)\n            if self.impl.micSetInputDevice then\n                return self.impl.micSetInputDevice(...)\n            end\n            return false\n        end,\n    }\n\n    return self\nend\n\nreturn AudioAdapter\n","audio_export.lua":"local AudioExport = {}\n\nlocal SAMPLE_RATE = 44100\nlocal TWO_PI = 2 * math.pi\n\nlocal function int16LE(n)\n    n = math.floor(n)\n    if n < 0 then\n        n = n + 65536\n    end\n    return string.char(n % 256, math.floor(n / 256) % 256)\nend\n\nlocal function int32LE(n)\n    n = math.floor(n)\n    if n < 0 then\n        n = n + 4294967296\n    end\n    return string.char(\n        n % 256,\n        math.floor(n / 256) % 256,\n        math.floor(n / 65536) % 256,\n        math.floor(n / 16777216) % 256\n    )\nend\n\nlocal function buildWavFromSamples(samples)\n    local pcm = {}\n    for i = 1, #samples do\n        local sample = math.max(-32767, math.min(32767, math.floor(samples[i] * 32767)))\n        pcm[i] = int16LE(sample)\n    end\n\n    local pcmData = table.concat(pcm)\n    local dataSize = #pcmData\n    return \"RIFF\" .. int32LE(36 + dataSize) .. \"WAVEfmt \" .. int32LE(16) ..\n        int16LE(1) .. int16LE(1) .. int32LE(SAMPLE_RATE) ..\n        int32LE(SAMPLE_RATE * 2) .. int16LE(2) .. int16LE(16) ..\n        \"data\" .. int32LE(dataSize) .. pcmData\nend\n\nlocal function sineWave(phase)\n    return math.sin(phase)\nend\n\nlocal function triangleWave(phase)\n    local t = (phase / TWO_PI) % 1.0\n    if t < 0.25 then\n        return 4 * t\n    elseif t < 0.75 then\n        return 2 - 4 * t\n    end\n    return -4 + 4 * t\nend\n\nlocal function sawtoothWave(phase)\n    return (math.sin(phase)\n          + math.sin(phase * 2) * 0.5\n          + math.sin(phase * 3) * 0.333\n          + math.sin(phase * 4) * 0.25\n          + math.sin(phase * 5) * 0.2\n          + math.sin(phase * 6) * 0.167) * 0.5\nend\n\nlocal function warmWave(phase)\n    return (math.sin(phase) * 0.70\n          + math.sin(phase * 2) * 0.20\n          + math.sin(phase * 3) * 0.075\n          + math.sin(phase * 4) * 0.025) * 0.92\nend\n\nlocal function vocalWave(phase)\n    return (math.sin(phase) * 0.50\n          + math.sin(phase * 2) * 0.27\n          + math.sin(phase * 3) * 0.16\n          + math.sin(phase * 4) * 0.075\n          + math.sin(phase * 5) * 0.03\n          + math.sin(phase * 6) * 0.012) * 0.74\nend\n\nlocal function fluteWave(phase)\n    return (math.sin(phase) * 0.82\n          + math.sin(phase * 2) * 0.13\n          + math.sin(phase * 3) * 0.035\n          + math.sin(phase * 4) * 0.015) * 0.88\nend\n\nlocal function softClip(sample)\n    return sample / (1.0 + math.abs(sample))\nend\n\nlocal WAVE_GENERATORS = {\n    sine = sineWave,\n    triangle = triangleWave,\n    sawtooth = sawtoothWave,\n    warm = warmWave,\n    vocal = vocalWave,\n    flute = fluteWave,\n}\n\nlocal function renderVoice(samples, startSample, durationSeconds, freq, amplitude, waveType, adsr)\n    if amplitude <= 0 or durationSeconds <= 0 or not freq or freq <= 0 then\n        return\n    end\n\n    local wave = WAVE_GENERATORS[waveType] or triangleWave\n    local totalDuration = math.max(durationSeconds, 0.35) + adsr.r\n    local totalSamples = math.max(1, math.floor(totalDuration * SAMPLE_RATE))\n    local phaseInc = TWO_PI * freq / SAMPLE_RATE\n    local phase = 0\n    local phase2 = 0\n    local phaseInc2 = TWO_PI * (freq * 1.00115) / SAMPLE_RATE\n    local phase3 = math.pi * 0.37\n    local phaseInc3 = TWO_PI * (freq * 0.99885) / SAMPLE_RATE\n    local previousSample = 0\n    local fadeSamples = math.max(1, math.floor(SAMPLE_RATE * 0.01))\n    local isFlute = waveType == \"flute\"\n    local vibPhase = 0\n    local vibInc = TWO_PI * 5.0 / SAMPLE_RATE\n    local vibDepth = 0.0038\n\n    for i = 0, totalSamples - 1 do\n        local t = i / SAMPLE_RATE\n        local env\n        if t < adsr.a then\n            env = t / adsr.a\n        elseif t < adsr.a + adsr.d then\n            local dt = (t - adsr.a) / adsr.d\n            local curve = (1.0 - dt) * (1.0 - dt)\n            env = adsr.s + (1.0 - adsr.s) * curve\n        elseif t < durationSeconds then\n            env = adsr.s\n        else\n            local rt = math.min(1, (t - durationSeconds) / adsr.r)\n            env = adsr.s * (1.0 - rt) * (1.0 - rt)\n        end\n\n        local remaining = totalSamples - i\n        if remaining <= fadeSamples then\n            env = env * (remaining / fadeSamples)\n        end\n\n        local sampleIndex = startSample + i + 1\n        local osc\n        if isFlute then\n            local vibAmt = math.min(1.0, math.max(0.0, (t - adsr.a) / 0.22))\n            local vib = vibAmt * vibDepth * math.sin(vibPhase)\n            local tremolo = 1.0 + 0.018 * math.sin(vibPhase * 0.79)\n            local breath = (math.random() - 0.5) * 0.018 * math.min(1.0, env * 1.8)\n            osc = (wave(phase) * 0.82 + wave(phase2) * 0.12 + wave(phase3) * 0.06) * tremolo + breath\n            phase = phase + phaseInc * (1.0 + vib)\n            phase2 = phase2 + phaseInc2 * (1.0 + vib)\n            phase3 = phase3 + phaseInc3 * (1.0 + vib)\n            vibPhase = vibPhase + vibInc\n        else\n            osc = wave(phase) * 0.62 + wave(phase2) * 0.25 + wave(phase3) * 0.13\n            phase = phase + phaseInc\n            phase2 = phase2 + phaseInc2\n            phase3 = phase3 + phaseInc3\n        end\n        local shaped = osc * amplitude * env\n        shaped = previousSample * 0.18 + shaped * 0.82\n        previousSample = shaped\n        samples[sampleIndex] = (samples[sampleIndex] or 0) + softClip(shaped)\n    end\nend\n\nlocal function getVelocity(stepIndex)\n    if stepIndex % 4 == 1 then\n        return 1.0\n    elseif stepIndex % 4 == 3 then\n        return 0.85\n    end\n    return 0.9\nend\n\nlocal function getStepLayers(isMacDesktop)\n    local secondaryVolume = isMacDesktop and 0 or 0.18\n    local chordSecondaryVolume = isMacDesktop and 0 or 0.12\n\n    return {\n        single = {\n            { wave = \"flute\", volume = 0.68, adsr = { a = 0.045, d = 0.12, s = 0.78, r = 0.34 } },\n            { wave = \"sine\", volume = secondaryVolume, adsr = { a = 0.05, d = 0.12, s = 0.52, r = 0.34 } }\n        },\n        chord = {\n            { wave = \"flute\", volume = 0.68, adsr = { a = 0.045, d = 0.12, s = 0.78, r = 0.34 } },\n            { wave = \"flute\", volume = 0.2, adsr = { a = 0.05, d = 0.14, s = 0.68, r = 0.34 } },\n            { wave = \"flute\", volume = 0.2, adsr = { a = 0.05, d = 0.14, s = 0.68, r = 0.34 } },\n            { wave = \"sine\", volume = secondaryVolume, adsr = { a = 0.05, d = 0.12, s = 0.52, r = 0.34 } },\n            { wave = \"sine\", volume = chordSecondaryVolume, adsr = { a = 0.055, d = 0.14, s = 0.48, r = 0.36 } },\n            { wave = \"sine\", volume = chordSecondaryVolume, adsr = { a = 0.055, d = 0.14, s = 0.48, r = 0.36 } }\n        }\n    }\nend\n\nlocal function renderTone(samples, startSeconds, noteIndex, octave, durationSeconds, velocity, layers, noteFreqs)\n    local baseFreq = noteFreqs[noteIndex]\n    if not baseFreq then\n        return\n    end\n\n    local octaveMultiplier = 2 ^ ((octave or 4) - 4)\n    local freq = baseFreq * octaveMultiplier\n    local startSample = math.max(0, math.floor(startSeconds * SAMPLE_RATE))\n\n    for _, layer in ipairs(layers) do\n        if layer then\n            renderVoice(\n                samples,\n                startSample,\n                durationSeconds,\n                freq,\n                layer.volume * velocity,\n                layer.wave,\n                layer.adsr\n            )\n        end\n    end\nend\n\nfunction AudioExport.renderActiveSequenceWav(options)\n    local state = assert(options.state, \"state is required\")\n    local core = assert(options.core, \"core is required\")\n    local noteFreqs = assert(options.noteFreqs, \"noteFreqs is required\")\n    local transposeNoteForKey = assert(options.transposeNoteForKey, \"transposeNoteForKey is required\")\n    local sequenceIndex = options.sequenceIndex or state.activeSequenceIndex or 1\n    local sequence = state.sequences and state.sequences[sequenceIndex]\n    local sequenceLength = (state.sequenceLengths and state.sequenceLengths[sequenceIndex]) or 0\n\n    if not sequence or sequenceLength <= 0 then\n        return false, \"active sequence is empty\"\n    end\n\n    local stepDurationSeconds = (state.stepDuration or 500) / 1000\n    local currentTime = 0\n    local layers = getStepLayers(options.isMacDesktop == true)\n    local masterVolume = math.max(0, math.min(1, tonumber(state.masterVolume) or 1))\n    local octaveTranspose = 0\n    if state.sequenceOctaveTranspose then\n        octaveTranspose = state.sequenceOctaveTranspose[sequenceIndex] or 0\n    end\n\n    local samples = {}\n\n    if state.playKeyBeforeSteps then\n        local keyOctave = math.max(2, math.min(7, (state.keyOctave or 4) + octaveTranspose))\n        local keyNote, transposedKeyOctave = transposeNoteForKey(state.keyNote or 0, keyOctave)\n        renderTone(samples, 0, keyNote, transposedKeyOctave, math.max(0.05, stepDurationSeconds * 0.9), 1.0 * masterVolume, layers.single, noteFreqs)\n        currentTime = ((state.keyLeadInBeats or 1) * stepDurationSeconds) + stepDurationSeconds\n    end\n\n    local stepIndex = 1\n    while stepIndex <= sequenceLength do\n        local stepData = sequence[stepIndex]\n        local stepLength = 1\n        if stepData then\n            stepLength = core.getStepLength(stepData)\n        end\n        local gate = (stepData and stepData.gate) or 0.9\n        local noteDurationSeconds = math.max(0.05, stepDurationSeconds * stepLength * gate)\n        local velocity = getVelocity(stepIndex) * masterVolume\n\n        if stepData and not stepData.muted then\n            if core.isChord(stepData) then\n                for noteLayerIndex, noteData in ipairs(stepData.notes or {}) do\n                    if noteLayerIndex > 3 then\n                        break\n                    end\n                    if noteData and noteData.note ~= nil and noteData.note ~= 13 and noteFreqs[noteData.note] ~= nil then\n                        local transposedNote, transposedOctave = transposeNoteForKey(noteData.note, noteData.octave)\n                        transposedOctave = math.max(2, math.min(7, (transposedOctave or 4) + octaveTranspose))\n                        renderTone(\n                            samples,\n                            currentTime,\n                            transposedNote,\n                            transposedOctave,\n                            noteDurationSeconds,\n                            velocity,\n                            { layers.chord[noteLayerIndex], layers.chord[noteLayerIndex + 3] },\n                            noteFreqs\n                        )\n                    end\n                end\n            elseif stepData.note ~= nil and stepData.note ~= 13 and noteFreqs[stepData.note] ~= nil then\n                local transposedNote, transposedOctave = transposeNoteForKey(stepData.note, stepData.octave)\n                transposedOctave = math.max(2, math.min(7, (transposedOctave or 4) + octaveTranspose))\n                renderTone(samples, currentTime, transposedNote, transposedOctave, noteDurationSeconds, velocity, layers.single, noteFreqs)\n            end\n        end\n\n        currentTime = currentTime + (stepDurationSeconds * stepLength)\n        stepIndex = stepIndex + math.max(1, math.ceil(stepLength))\n    end\n\n    local totalSamples = math.max(1, math.ceil((currentTime + 0.6) * SAMPLE_RATE))\n    local normalized = {}\n    local peak = 0\n    for i = 1, totalSamples do\n        local value = samples[i] or 0\n        if math.abs(value) > peak then\n            peak = math.abs(value)\n        end\n        normalized[i] = value\n    end\n\n    local scale = peak > 0.98 and (0.98 / peak) or 1\n    for i = 1, totalSamples do\n        normalized[i] = normalized[i] * scale\n    end\n\n    return true, buildWavFromSamples(normalized)\nend\n\nreturn AudioExport\n","clipboard.lua":"-- clipboard.lua — cross-platform clipboard read/write\n-- Backends: pbcopy/pbpaste (macOS), wl-copy/wl-paste (Linux/Wayland),\n--           xclip or xsel (Linux/X11), clip+powershell (Windows), memory fallback.\n-- Detection runs once (lazy) and caches the result.\n\nlocal M = {}\n\nlocal _backend = nil  -- detected lazily\nlocal _memClip  = \"\"  -- in-process fallback (always updated on write)\n\nlocal function probe(cmd)\n    local fh = io.popen(cmd .. \" 2>/dev/null\", \"r\")\n    if not fh then return false end\n    local out = fh:read(\"*l\") or \"\"\n    fh:close()\n    return out ~= \"\"\nend\n\nlocal function detectBackend()\n    -- Playdate: no system clipboard\n    if _G.playdate then\n        return {type = \"memory\"}\n    end\n\n    -- Web: use browser Clipboard API via input bridge\n    if rawget(_G, \"SOLFEGE_PLATFORM\") == \"web\" then\n        return {type = \"web\"}\n    end\n\n    -- Detect OS via uname (cached after this)\n    local os_name = \"\"\n    local fh = io.popen(\"uname 2>/dev/null\", \"r\")\n    if fh then\n        os_name = (fh:read(\"*l\") or \"\"):lower()\n        fh:close()\n    end\n\n    if os_name:find(\"darwin\") then\n        return {type = \"pbcopy\"}\n\n    elseif os_name:find(\"linux\") then\n        -- Prefer Wayland wl-clipboard if WAYLAND_DISPLAY is set\n        local wayland = os.getenv(\"WAYLAND_DISPLAY\") or \"\"\n        if wayland ~= \"\" and probe(\"command -v wl-copy\") then\n            return {type = \"wl\", write = \"wl-copy\", read = \"wl-paste --no-newline\"}\n        end\n        if probe(\"command -v xclip\") then\n            return {type = \"xclip\",\n                    write = \"xclip -selection clipboard\",\n                    read  = \"xclip -selection clipboard -o\"}\n        end\n        if probe(\"command -v xsel\") then\n            return {type = \"xsel\",\n                    write = \"xsel --clipboard --input\",\n                    read  = \"xsel --clipboard --output\"}\n        end\n        return {type = \"memory\"}\n\n    else\n        -- Windows (MINGW / MSYS / CYGWIN or unknown)\n        if probe(\"where clip 2>NUL || command -v clip\") then\n            return {type = \"win\"}\n        end\n        return {type = \"memory\"}\n    end\nend\n\nlocal function getBackend()\n    if not _backend then\n        _backend = detectBackend()\n    end\n    return _backend\nend\n\nfunction M.write(text)\n    text = text or \"\"\n    _memClip = text  -- always update in-memory copy as fallback\n\n    local b = getBackend()\n    if b.type == \"memory\" then return end\n\n    if b.type == \"web\" then\n        local bridge = rawget(_G, \"WebHost\") and _G.WebHost.input or nil\n        if bridge and bridge.copyToClipboard then\n            bridge.copyToClipboard(text)\n        end\n        return\n    end\n\n    local ok, fh\n    if b.type == \"pbcopy\" then\n        fh = io.popen(\"pbcopy\", \"w\")\n    elseif b.type == \"wl\" or b.type == \"xclip\" or b.type == \"xsel\" then\n        fh = io.popen(b.write, \"w\")\n    elseif b.type == \"win\" then\n        fh = io.popen(\"clip\", \"w\")\n    end\n    if fh then\n        fh:write(text)\n        ok = fh:close()\n    end\n    if not ok then\n        -- External tool failed; _memClip already updated, so in-app paste still works.\n    end\nend\n\nfunction M.read()\n    local b = getBackend()\n    if b.type == \"memory\" then return _memClip end\n\n    local fh, text\n    if b.type == \"pbcopy\" then\n        fh = io.popen(\"pbpaste\", \"r\")\n    elseif b.type == \"wl\" or b.type == \"xclip\" or b.type == \"xsel\" then\n        fh = io.popen(b.read, \"r\")\n    elseif b.type == \"win\" then\n        fh = io.popen(\"powershell -NoProfile -Command \\\"Get-Clipboard\\\"\", \"r\")\n    end\n    if fh then\n        text = fh:read(\"*a\") or \"\"\n        fh:close()\n        -- Strip trailing newline(s) added by some tools\n        text = text:gsub(\"\\r\\n$\", \"\"):gsub(\"\\n$\", \"\")\n        return text\n    end\n    return _memClip  -- fallback if read fails\nend\n\n-- For testing: force a specific backend without probing the OS.\nfunction M._setBackendForTest(b)\n    _backend = b\n    _memClip = \"\"\nend\n\nreturn M\n","cmd_chat.lua":"local cmdChat = {}\n\nlocal _state, _core, _tl, _actions\n\nlocal NOTE_NAMES = {\n    c=0, [\"c#\"]=1, db=1, d=2, [\"d#\"]=3, eb=3, e=4, f=5,\n    [\"f#\"]=6, gb=6, g=7, [\"g#\"]=8, ab=8, a=9, [\"a#\"]=10, bb=10, b=11\n}\nlocal NOTE_DISPLAY = {\"C\",\"C#\",\"D\",\"D#\",\"E\",\"F\",\"F#\",\"G\",\"G#\",\"A\",\"A#\",\"B\"}\n\nlocal SOLFEGE_NAME_TO_NOTE = {\n    [\"do\"] = true, [\"di\"] = true, [\"ra\"] = true,\n    [\"re\"] = true, [\"ri\"] = true, [\"me\"] = true,\n    [\"mi\"] = true,\n    [\"fa\"] = true, [\"fi\"] = true, [\"se\"] = true,\n    [\"sol\"] = true, [\"si\"] = true, [\"le\"] = true,\n    [\"la\"] = true, [\"li\"] = true, [\"te\"] = true,\n    [\"ti\"] = true,\n    [\"do'\"] = true,\n    [\"rest\"] = true, [\"r\"] = true, [\"--\"] = true,\n}\n\nlocal SOLFEGE_NOTE_INDEX = {\n    [\"do\"] = 0, [\"di\"] = 1, [\"ra\"] = 1,\n    [\"re\"] = 2, [\"ri\"] = 3, [\"me\"] = 3,\n    [\"mi\"] = 4,\n    [\"fa\"] = 5, [\"fi\"] = 6, [\"se\"] = 6,\n    [\"sol\"] = 7, [\"si\"] = 8, [\"le\"] = 8,\n    [\"la\"] = 9, [\"li\"] = 10, [\"te\"] = 10,\n    [\"ti\"] = 11,\n    [\"do'\"] = 12,\n}\n\nlocal SCALE_PHRASES = {\n    major           = \"do re mi fa sol la ti do' ti la sol fa mi re do\",\n    minor           = \"do re me fa sol le te do' te le sol fa me re do\",\n    [\"natural minor\"]  = \"do re me fa sol le te do' te le sol fa me re do\",\n    [\"harmonic minor\"] = \"do re me fa sol le ti do' ti le sol fa me re do\",\n    [\"melodic minor\"]  = \"do re me fa sol la ti do' te le sol fa me re do\",\n    dorian          = \"do re me fa sol la te do' te la sol fa me re do\",\n    phrygian        = \"do ra me fa sol le te do' te le sol fa me ra do\",\n    lydian          = \"do re mi fi sol la ti do' ti la sol fi mi re do\",\n    mixolydian      = \"do re mi fa sol la te do' te la sol fa mi re do\",\n    locrian         = \"do ra me fa se le te do' te le se fa me ra do\",\n    pentatonic      = \"do re mi sol la do' la sol mi re do\",\n    [\"major pentatonic\"] = \"do re mi sol la do' la sol mi re do\",\n    [\"minor pentatonic\"] = \"do me fa sol te do' te sol fa me do\",\n    blues           = \"do me fa fi sol te do' te sol fi fa me do\",\n    chromatic       = \"do di re ri mi fa fi sol si la li ti do' ti li la si sol fi fa mi ri re di do\",\n    [\"whole tone\"]  = \"do re mi fi si li do' li si fi mi re do\",\n}\n\nlocal SCALE_ALIASES = {\n    maj = \"major\", min = \"minor\", nat = \"natural minor\",\n    [\"nat minor\"] = \"natural minor\", [\"natural min\"] = \"natural minor\",\n    harm = \"harmonic minor\", [\"harm minor\"] = \"harmonic minor\", [\"harmonic min\"] = \"harmonic minor\",\n    mel = \"melodic minor\", [\"mel minor\"] = \"melodic minor\", [\"melodic min\"] = \"melodic minor\",\n    dor = \"dorian\", phryg = \"phrygian\", lyd = \"lydian\", mixo = \"mixolydian\",\n    mix = \"mixolydian\", loc = \"locrian\", pent = \"pentatonic\",\n    [\"maj pent\"] = \"major pentatonic\", [\"major pent\"] = \"major pentatonic\",\n    [\"min pent\"] = \"minor pentatonic\", [\"minor pent\"] = \"minor pentatonic\",\n    [\"whole\"] = \"whole tone\", [\"wholetone\"] = \"whole tone\",\n    chrom = \"chromatic\",\n}\n\nlocal function resolveScaleName(raw)\n    raw = trim(raw):lower():gsub(\"%s+scale$\", \"\"):gsub(\"%s+\", \" \")\n    if SCALE_PHRASES[raw] then return raw end\n    if SCALE_ALIASES[raw] then return SCALE_ALIASES[raw] end\n    for name in pairs(SCALE_PHRASES) do\n        if name:find(raw, 1, true) then return name end\n    end\n    return nil\nend\n\nlocal function scaleNamesListText()\n    local ordered = {\n        \"major\", \"minor\", \"harmonic minor\", \"melodic minor\",\n        \"dorian\", \"phrygian\", \"lydian\", \"mixolydian\", \"locrian\",\n        \"pentatonic\", \"major pentatonic\", \"minor pentatonic\",\n        \"blues\", \"chromatic\", \"whole tone\",\n    }\n    return table.concat(ordered, \", \")\nend\n\nlocal function buildScalePhrase(input)\n    local lower = trim(input):lower()\n    local parts = {}\n    for segment in (lower .. \" then \"):gmatch(\"(.-)%s+then%s+\") do\n        segment = trim(segment)\n        if segment ~= \"\" then\n            local name = resolveScaleName(segment)\n            if not name then return nil end\n            parts[#parts + 1] = { name = name, phrase = SCALE_PHRASES[name] }\n        end\n    end\n    if #parts == 0 then\n        local name = resolveScaleName(lower)\n        if not name then return nil end\n        parts[#parts + 1] = { name = name, phrase = SCALE_PHRASES[name] }\n    end\n    local combined = {}\n    local labels = {}\n    for _, p in ipairs(parts) do\n        combined[#combined + 1] = p.phrase\n        labels[#labels + 1] = p.name\n    end\n    return table.concat(combined, \" \"), table.concat(labels, \" then \")\nend\n\nlocal commands = {}\nlocal aliases = {}\nlocal commandOrder = {}\n\nlocal function trim(s)\n    return (s or \"\"):match(\"^%s*(.-)%s*$\")\nend\n\nlocal function normalizeSpaces(s)\n    return trim(tostring(s or \"\"):gsub(\"%s+\", \" \"))\nend\n\nlocal function isOneOf(value, choices)\n    for _, choice in ipairs(choices) do\n        if value == choice then return true end\n    end\n    return false\nend\n\nlocal function parseToggleState(raw)\n    local text = trim(raw):lower()\n    if text == \"\" or text == \"toggle\" then return nil end\n    if text == \"on\" or text == \"show\" or text == \"shown\" or text == \"visible\" or text == \"yes\" then\n        return true\n    end\n    if text == \"off\" or text == \"hide\" or text == \"hidden\" or text == \"invisible\" or text == \"no\" then\n        return false\n    end\n    return nil, \"Usage: view <note-lengths|octaves> [show|hide|toggle]\"\nend\n\nlocal function onOffLabel(value)\n    return value and \"ON\" or \"OFF\"\nend\n\nlocal function addCommand(name, usage, description, handler, opts)\n    commands[name] = {\n        usage = usage,\n        description = description,\n        handler = handler,\n        hidden = opts and opts.hidden\n    }\n    commandOrder[#commandOrder + 1] = name\nend\n\nlocal function addAlias(alias, commandName)\n    aliases[alias] = commandName\nend\n\nlocal function resolveCommandName(name)\n    name = (name or \"\"):lower()\n    return aliases[name] or name\nend\n\nlocal function editDistance(a, b)\n    a = tostring(a or \"\")\n    b = tostring(b or \"\")\n    local prev = {}\n    for j = 0, #b do prev[j] = j end\n    for i = 1, #a do\n        local current = {[0] = i}\n        local ca = a:sub(i, i)\n        for j = 1, #b do\n            local cost = ca == b:sub(j, j) and 0 or 1\n            current[j] = math.min(\n                prev[j] + 1,\n                current[j - 1] + 1,\n                prev[j - 1] + cost\n            )\n        end\n        prev = current\n    end\n    return prev[#b]\nend\n\nlocal function closestCommandName(rawName)\n    local query = tostring(rawName or \"\"):lower()\n    if query == \"\" then return nil end\n    local bestName, bestScore\n    local function consider(name)\n        local lower = tostring(name or \"\"):lower()\n        local score\n        if lower:find(query, 1, true) == 1 or query:find(lower, 1, true) == 1 then\n            score = math.abs(#lower - #query)\n        else\n            score = editDistance(query, lower)\n        end\n        if not bestScore or score < bestScore or (score == bestScore and lower < bestName) then\n            bestName = lower\n            bestScore = score\n        end\n    end\n    for name, entry in pairs(commands) do\n        if entry and not entry.hidden then consider(name) end\n    end\n    for alias in pairs(aliases) do consider(alias) end\n    if bestName and bestScore and bestScore <= math.max(1, math.floor(#query / 3)) then\n        return resolveCommandName(bestName)\n    end\n    return nil\nend\n\nlocal function tokenizeSolfegeChatText(text)\n    local tokens = {}\n    local chordParts\n    for raw in tostring(text or \"\"):gmatch(\"%S+\") do\n        if chordParts then\n            chordParts[#chordParts + 1] = raw\n            if raw:find(\">\") then\n                tokens[#tokens + 1] = table.concat(chordParts, \" \")\n                chordParts = nil\n            end\n        elseif raw:sub(1, 1) == \"<\" and not raw:find(\">\") then\n            chordParts = {raw}\n        else\n            tokens[#tokens + 1] = raw\n        end\n    end\n    if chordParts then\n        tokens[#tokens + 1] = table.concat(chordParts, \" \")\n    end\n    return tokens\nend\n\nlocal function solfegeSyllableFromToken(raw)\n    local corePart = tostring(raw or \"\"):match(\"^(.-)|\") or tostring(raw or \"\")\n    local notePart = corePart:match(\"^(.-)/\") or corePart\n    return notePart:match(\"^([A-Za-z][A-Za-z'%-]*)(%d*)$\") or notePart\nend\n\nlocal function isSolfegeChatAtom(raw)\n    local syllable = solfegeSyllableFromToken(raw)\n    return SOLFEGE_NAME_TO_NOTE[syllable:lower()] == true\nend\n\nlocal function isSolfegeChatChord(raw)\n    local token = trim(raw)\n    if token:sub(1, 1) ~= \"<\" or token:find(\">\") == nil then\n        return false\n    end\n    token = token:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n    if token == \"\" then return false end\n\n    local count = 0\n    for part in token:gmatch(\"%S+\") do\n        count = count + 1\n        part = part:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n        if not isSolfegeChatAtom(part) then\n            return false\n        end\n    end\n    return count > 0\nend\n\nlocal function isSolfegeChatToken(raw)\n    return isSolfegeChatAtom(raw) or isSolfegeChatChord(raw)\nend\n\nlocal function parseSolfegeChatTokens(text)\n    local tokens = {}\n    for _, raw in ipairs(tokenizeSolfegeChatText(text)) do\n        if not isSolfegeChatToken(raw) then\n            return nil\n        end\n        tokens[#tokens + 1] = raw\n    end\n    if #tokens == 0 then return nil end\n    return tokens\nend\n\nlocal function collectSolfegeChatTokens(text)\n    local tokens = {}\n    for _, raw in ipairs(tokenizeSolfegeChatText(text)) do\n        local token = raw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\")\n        local lower = token:lower()\n        local isMeta = token == \"|\" or token == \"||\"\n            or lower:match(\"^key:\") or lower:match(\"^scale:\") or lower:match(\"^bpm:\")\n            or lower:match(\"^meter:\") or lower:match(\"^loop:\") or lower:match(\"^octave:\")\n            or lower:match(\"^t:\") or lower:match(\"^transpose:\") or lower:match(\"^length:\")\n            or lower:match(\"^oct:\") or lower:match(\"^default:\")\n        if not isMeta then\n            if isSolfegeChatToken(token) then\n                tokens[#tokens + 1] = token\n            end\n        end\n    end\n    if #tokens == 0 then return nil end\n    return tokens\nend\n\nlocal function handleSolfegeText(text, playIt, addIt, forcePlay)\n    local tokens = parseSolfegeChatTokens(text)\n    if not tokens then\n        return nil\n    end\n\n    local solfegeText = table.concat(tokens, \" \")\n    if addIt and _actions.addSolfegeText then\n        _actions.addSolfegeText(solfegeText)\n    end\n    if playIt and _actions.playSolfegeText then\n        local playText = solfegeText\n        if addIt and _actions.getSolfegeText then\n            playText = _actions.getSolfegeText()\n        end\n        -- Loop if loopPlayback is on OR if there's already an active audition loop\n        local shouldLoop = _state.loopPlayback == true\n            or _state._cmdChatAuditionLoopText ~= nil\n        local ok, err = _actions.playSolfegeText(playText, shouldLoop, forcePlay == true)\n        if ok == false then\n            return err or \"Nothing to play\"\n        end\n    end\n\n    if addIt and playIt then\n        return \"Added and played: \" .. solfegeText\n    elseif addIt then\n        return \"Added: \" .. solfegeText\n    elseif playIt then\n        return \"Played: \" .. solfegeText\n    end\n    return solfegeText\nend\n\nlocal function solfegeTextOrUsage(text, usage)\n    local tokens = parseSolfegeChatTokens(text)\n    if not tokens then\n        return nil, usage\n    end\n    return table.concat(tokens, \" \"), nil\nend\n\nlocal function getSolfegeDisplayName(noteIndex)\n    local notes = _core and _core.getSolfegeNotes and _core.getSolfegeNotes(_state.solfegeScale)\n        or _core and _core.solfegeNotes\n        or {}\n    return notes[(noteIndex or 0) + 1] or NOTE_DISPLAY[((noteIndex or 0) % 12) + 1] or tostring(noteIndex)\nend\n\nlocal function parseDroneNote(raw)\n    local text = trim(raw):lower()\n    if text == \"\" then return nil end\n    text = text:gsub(\"^to%s+\", \"\")\n    if SOLFEGE_NOTE_INDEX[text] ~= nil then\n        return SOLFEGE_NOTE_INDEX[text]\n    end\n    if NOTE_NAMES[text] ~= nil then\n        return NOTE_NAMES[text]\n    end\n    return nil\nend\n\nlocal function repeatSolfegeText(args)\n    local countRaw, rest = trim(args):match(\"^(%d+)%s+(.+)$\")\n    local count = tonumber(countRaw)\n    if not count or count < 1 or count > 32 then\n        return nil, \"Usage: repeat <1-32> do re mi\"\n    end\n    local solfegeText, err = solfegeTextOrUsage(rest, \"Usage: repeat <1-32> do re mi\")\n    if not solfegeText then return nil, err end\n    local parts = {}\n    for _ = 1, count do parts[#parts + 1] = solfegeText end\n    return table.concat(parts, \" \"), nil\nend\n\nlocal function applyTokenDuration(token, duration)\n    if isSolfegeChatChord(token) then\n        local body = token:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n        local parts = {}\n        for part in body:gmatch(\"%S+\") do\n            parts[#parts + 1] = part:gsub(\"/.*$\", \"\")\n        end\n        if #parts == 0 then return token end\n        parts[#parts] = parts[#parts] .. \"/\" .. duration\n        return \"<\" .. table.concat(parts, \" \") .. \">\"\n    end\n\n    local corePart, lyric = token:match(\"^(.-)|(.+)$\")\n    corePart = corePart or token\n    corePart = corePart:gsub(\"/.*$\", \"\")\n    return corePart .. \"/\" .. duration .. (lyric and (\"|\" .. lyric) or \"\")\nend\n\nlocal function splitSolfegeText(text)\n    local tokens = parseSolfegeChatTokens(text)\n    if not tokens then return nil end\n    return tokens\nend\n\nlocal function chordTextFromPhrase(text)\n    local tokens = parseSolfegeChatTokens(text)\n    if not tokens or #tokens == 0 then return nil end\n    if #tokens == 1 and isSolfegeChatChord(tokens[1]) then return tokens[1] end\n    for _, token in ipairs(tokens) do\n        if isSolfegeChatChord(token) then return nil end\n    end\n    return \"<\" .. table.concat(tokens, \" \") .. \">\"\nend\n\nlocal function joinTokens(tokens)\n    return table.concat(tokens, \" \")\nend\n\nlocal function stripKeyHeader(text)\n    text = tostring(text or \"\")\n    if text:match(\"^%s*Key:\") then\n        text = text:gsub(\"^%s*[^\\n]*\\n?\", \"\", 1)\n    end\n    return trim(text)\nend\n\nlocal function getCurrentSolfegeTokens()\n    local text = _actions.getSolfegeText and _actions.getSolfegeText()\n        or _state.solfegeInputBuffer\n        or _state._solfegeSeqText\n        or \"\"\n    text = stripKeyHeader(text)\n    if text == \"\" then return {} end\n    local tokens = parseSolfegeChatTokens(text)\n    if not tokens then\n        return nil, \"Current sequence contains tokens chat cannot edit yet\"\n    end\n    return tokens, nil\nend\n\nlocal function getCurrentSolfegeText()\n    local tokens, err = getCurrentSolfegeTokens()\n    if not tokens then return nil, err end\n    if #tokens == 0 then return nil, \"No solfege to play yet\" end\n    return joinTokens(tokens), nil\nend\n\nlocal function tokenPartsForAnalysis(token)\n    local parts = {}\n    token = trim(token)\n    if isSolfegeChatChord(token) then\n        local body = token:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n        for part in body:gmatch(\"%S+\") do\n            parts[#parts + 1] = part\n        end\n    elseif token ~= \"\" then\n        parts[1] = token\n    end\n    return parts\nend\n\nlocal function parseAnalysisAtom(raw)\n    local token = tostring(raw or \"\")\n    local corePart, lyric = token:match(\"^(.-)|(.+)$\")\n    corePart = corePart or token\n    local notePart, duration = corePart:match(\"^(.-)/(.+)$\")\n    notePart = notePart or corePart\n    local syllable, octaveText = notePart:match(\"^([A-Za-z][A-Za-z'%-]*)(%d*)$\")\n    syllable = syllable or notePart\n    local lower = syllable:lower()\n    local noteIndex = SOLFEGE_NOTE_INDEX[lower]\n    local isRest = lower == \"rest\" or lower == \"r\" or lower == \"--\"\n    return {\n        raw = token,\n        syllable = syllable,\n        lower = lower,\n        noteIndex = noteIndex,\n        octave = octaveText ~= \"\" and tonumber(octaveText) or nil,\n        duration = duration,\n        lyric = lyric,\n        isRest = isRest,\n    }\nend\n\nlocal function parseTokenAtomParts(token)\n    local corePart, lyric = tostring(token or \"\"):match(\"^(.-)|(.+)$\")\n    corePart = corePart or tostring(token or \"\")\n    local restDuration = corePart:match(\"^(%-%-)/?(.*)$\")\n    if restDuration ~= nil then\n        local duration = corePart:match(\"^%-%-/(.+)$\")\n        return {\n            syllable = \"--\",\n            lower = \"--\",\n            octave = \"\",\n            duration = duration,\n            lyric = lyric,\n        }\n    end\n    local notePart, duration = corePart:match(\"^(.-)/(.+)$\")\n    notePart = notePart or corePart\n    local syllable, octaveText = notePart:match(\"^([A-Za-z][A-Za-z'%-]*)(%d*)$\")\n    if not syllable then return nil end\n    return {\n        syllable = syllable,\n        lower = syllable:lower(),\n        octave = octaveText or \"\",\n        duration = duration,\n        lyric = lyric,\n    }\nend\n\nlocal function replaceTokenSyllable(token, fromLower, toSyllable)\n    if isSolfegeChatChord(token) then\n        local body = token:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n        local changed = false\n        local out = {}\n        for part in body:gmatch(\"%S+\") do\n            local replaced, partChanged = replaceTokenSyllable(part, fromLower, toSyllable)\n            out[#out + 1] = replaced\n            changed = changed or partChanged\n        end\n        return \"<\" .. table.concat(out, \" \") .. \">\", changed\n    end\n\n    local parsed = parseTokenAtomParts(token)\n    if not parsed or parsed.lower ~= fromLower then\n        return token, false\n    end\n    local nextToken = toSyllable == \"--\" and \"--\" or (toSyllable .. parsed.octave)\n    if parsed.duration and parsed.duration ~= \"\" then\n        nextToken = nextToken .. \"/\" .. parsed.duration\n    end\n    if parsed.lyric and parsed.lyric ~= \"\" then\n        nextToken = nextToken .. \"|\" .. parsed.lyric\n    end\n    return nextToken, true\nend\n\nlocal function canonicalSolfegeSyllable(raw)\n    local text = trim(raw):lower()\n    if text == \"rest\" or text == \"r\" then return \"--\" end\n    if text == \"--\" then return \"--\" end\n    if SOLFEGE_NAME_TO_NOTE[text] and text ~= \"rest\" and text ~= \"r\" then\n        return text\n    end\n    return nil\nend\n\nlocal function scaleSyllableOrder()\n    local notes = _core and _core.getSolfegeNotes and _core.getSolfegeNotes(_state.solfegeScale)\n        or _core and _core.solfegeNotes\n        or {\"Do\", \"Di\", \"Re\", \"Ri\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\"}\n    local out = {}\n    local seen = {}\n    for _, note in ipairs(notes) do\n        local lower = tostring(note or \"\"):lower()\n        if lower ~= \"\" and lower ~= \"--\" and not seen[lower] then\n            out[#out + 1] = lower\n            seen[lower] = true\n        end\n    end\n    return out\nend\n\nlocal function shiftSyllableName(syllable, steps)\n    local lower = tostring(syllable or \"\"):lower()\n    local order = scaleSyllableOrder()\n    for i, name in ipairs(order) do\n        if name == lower then\n            local nextIndex = ((i - 1 + steps) % #order) + 1\n            return order[nextIndex]\n        end\n    end\n    return nil\nend\n\nlocal function shiftTokenSyllable(token, steps)\n    if isSolfegeChatChord(token) then\n        local body = token:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n        local changed = false\n        local out = {}\n        for part in body:gmatch(\"%S+\") do\n            local replaced, partChanged = shiftTokenSyllable(part, steps)\n            out[#out + 1] = replaced\n            changed = changed or partChanged\n        end\n        return \"<\" .. table.concat(out, \" \") .. \">\", changed\n    end\n\n    local parsed = parseTokenAtomParts(token)\n    if not parsed or parsed.lower == \"rest\" or parsed.lower == \"r\" or parsed.lower == \"--\" then\n        return token, false\n    end\n    local shifted = shiftSyllableName(parsed.syllable, steps)\n    if not shifted then return token, false end\n    local nextToken = shifted .. parsed.octave\n    if parsed.duration and parsed.duration ~= \"\" then\n        nextToken = nextToken .. \"/\" .. parsed.duration\n    end\n    if parsed.lyric and parsed.lyric ~= \"\" then\n        nextToken = nextToken .. \"|\" .. parsed.lyric\n    end\n    return nextToken, true\nend\n\nlocal function getCurrentPatternAnalysis()\n    local tokens, err = getCurrentSolfegeTokens()\n    if not tokens then return nil, err end\n    if #tokens == 0 then return nil, \"No current solfege pattern yet\" end\n\n    local analysis = {\n        tokens = tokens,\n        stepCount = #tokens,\n        noteCount = 0,\n        chordCount = 0,\n        restCount = 0,\n        lyricCount = 0,\n        durationCounts = {},\n        octaveCounts = {},\n        notes = {},\n        intervals = {},\n        largestLeap = 0,\n        firstNote = nil,\n        lastNote = nil,\n        direction = \"mixed\",\n    }\n\n    local playable = {}\n    local totalDirection = 0\n    for _, token in ipairs(tokens) do\n        local isChord = isSolfegeChatChord(token)\n        if isChord then analysis.chordCount = analysis.chordCount + 1 end\n        local parts = tokenPartsForAnalysis(token)\n        local displayParts = {}\n        for _, part in ipairs(parts) do\n            local atom = parseAnalysisAtom(part)\n            displayParts[#displayParts + 1] = atom.syllable\n            if atom.isRest then\n                analysis.restCount = analysis.restCount + 1\n            elseif atom.noteIndex ~= nil then\n                analysis.noteCount = analysis.noteCount + 1\n                playable[#playable + 1] = atom.noteIndex\n                if atom.octave then\n                    analysis.octaveCounts[atom.octave] = (analysis.octaveCounts[atom.octave] or 0) + 1\n                end\n            end\n            if atom.duration and atom.duration ~= \"\" then\n                analysis.durationCounts[atom.duration] = (analysis.durationCounts[atom.duration] or 0) + 1\n            end\n            if atom.lyric and atom.lyric ~= \"\" then\n                analysis.lyricCount = analysis.lyricCount + 1\n            end\n        end\n        if isChord then\n            analysis.notes[#analysis.notes + 1] = \"<\" .. table.concat(displayParts, \" \") .. \">\"\n        else\n            analysis.notes[#analysis.notes + 1] = displayParts[1] or token\n        end\n    end\n\n    for i = 2, #playable do\n        local interval = playable[i] - playable[i - 1]\n        analysis.intervals[#analysis.intervals + 1] = interval\n        if math.abs(interval) > math.abs(analysis.largestLeap) then\n            analysis.largestLeap = interval\n        end\n        if interval > 0 then totalDirection = totalDirection + 1\n        elseif interval < 0 then totalDirection = totalDirection - 1 end\n    end\n    analysis.firstNote = analysis.notes[1]\n    analysis.lastNote = analysis.notes[#analysis.notes]\n    if #analysis.intervals > 0 then\n        if totalDirection == #analysis.intervals then\n            analysis.direction = \"ascending\"\n        elseif totalDirection == -#analysis.intervals then\n            analysis.direction = \"descending\"\n        elseif totalDirection > 0 then\n            analysis.direction = \"mostly ascending\"\n        elseif totalDirection < 0 then\n            analysis.direction = \"mostly descending\"\n        end\n    end\n    return analysis, nil\nend\n\nlocal function sortedCountKeys(counts)\n    local keys = {}\n    for key in pairs(counts or {}) do keys[#keys + 1] = key end\n    table.sort(keys, function(a, b)\n        local na, nb = tonumber(a), tonumber(b)\n        if na and nb then return na < nb end\n        return tostring(a) < tostring(b)\n    end)\n    return keys\nend\n\nlocal function formatCounts(counts, empty)\n    local parts = {}\n    for _, key in ipairs(sortedCountKeys(counts)) do\n        parts[#parts + 1] = tostring(key) .. \" x\" .. tostring(counts[key])\n    end\n    if #parts == 0 then return empty or \"none\" end\n    return table.concat(parts, \", \")\nend\n\nlocal function answerPatternQuestion(question)\n    local analysis, err = getCurrentPatternAnalysis()\n    if not analysis then return err end\n    local q = trim(question):lower()\n    q = q:gsub(\"%s*[%?%.!]$\", \"\")\n\n    if q == \"\" or q == \"summary\" or q == \"overview\" or q:find(\"describe\", 1, true)\n       or q:find(\"about\", 1, true)\n       or ((q:find(\"what is\", 1, true) or q:find(\"what's\", 1, true))\n           and (q:find(\"pattern\", 1, true) or q:find(\"melody\", 1, true))) then\n        return table.concat({\n            \"Current pattern: \" .. table.concat(analysis.notes, \" \"),\n            \"Steps: \" .. analysis.stepCount .. \" | Direction: \" .. analysis.direction,\n            \"Chords: \" .. analysis.chordCount .. \" | Rests: \" .. analysis.restCount .. \" | Lyrics: \" .. analysis.lyricCount,\n            \"Durations: \" .. formatCounts(analysis.durationCounts, \"default\"),\n        }, \"\\n\")\n    end\n\n    if q:find(\"how many\", 1, true) or q:find(\"count\", 1, true) or q:find(\"length\", 1, true) then\n        if q:find(\"chord\", 1, true) then\n            return \"The current pattern has \" .. analysis.chordCount .. \" chord step(s).\"\n        elseif q:find(\"rest\", 1, true) then\n            return \"The current pattern has \" .. analysis.restCount .. \" rest step(s).\"\n        elseif q:find(\"lyric\", 1, true) or q:find(\"word\", 1, true) then\n            return \"The current pattern has \" .. analysis.lyricCount .. \" lyric token(s).\"\n        elseif q:find(\"note\", 1, true) then\n            return \"The current pattern has \" .. analysis.noteCount .. \" sounding note(s) across \" .. analysis.stepCount .. \" step(s).\"\n        end\n        return \"The current pattern has \" .. analysis.stepCount .. \" step(s).\"\n    end\n\n    if q:find(\"first\", 1, true) or q:find(\"start\", 1, true) then\n        return \"The pattern starts on \" .. tostring(analysis.firstNote or \"nothing\") .. \".\"\n    end\n    if q:find(\"last\", 1, true) or q:find(\"end\", 1, true) then\n        return \"The pattern ends on \" .. tostring(analysis.lastNote or \"nothing\") .. \".\"\n    end\n    if q:find(\"direction\", 1, true) or q:find(\"ascending\", 1, true) or q:find(\"descending\", 1, true)\n       or q:find(\"up\", 1, true) or q:find(\"down\", 1, true) then\n        return \"Direction: \" .. analysis.direction .. \".\"\n    end\n    if q:find(\"leap\", 1, true) or q:find(\"skip\", 1, true) or q:find(\"stepwise\", 1, true) or q:find(\"interval\", 1, true) then\n        local hasLeap = math.abs(analysis.largestLeap or 0) > 2\n        local label = hasLeap and \"Yes\" or \"No\"\n        return label .. \". Largest melodic interval: \" .. tostring(analysis.largestLeap or 0) .. \" semitone(s).\"\n    end\n    if q:find(\"chord\", 1, true) then\n        return analysis.chordCount > 0 and (\"Yes. Chord steps: \" .. analysis.chordCount .. \".\") or \"No chord steps in the current pattern.\"\n    end\n    if q:find(\"rest\", 1, true) then\n        return analysis.restCount > 0 and (\"Yes. Rest steps: \" .. analysis.restCount .. \".\") or \"No rests in the current pattern.\"\n    end\n    if q:find(\"duration\", 1, true) or q:find(\"rhythm\", 1, true) or q:find(\"length\", 1, true) then\n        return \"Durations: \" .. formatCounts(analysis.durationCounts, \"default lengths\")\n    end\n    if q:find(\"octave\", 1, true) then\n        return \"Octaves: \" .. formatCounts(analysis.octaveCounts, \"default octave\")\n    end\n    if q:find(\"lyric\", 1, true) or q:find(\"word\", 1, true) then\n        return analysis.lyricCount > 0 and (\"Yes. Lyric tokens: \" .. analysis.lyricCount .. \".\") or \"No lyrics attached to this pattern.\"\n    end\n    if q:find(\"note\", 1, true) or q:find(\"solfege\", 1, true) or q:find(\"syllable\", 1, true) then\n        return \"Notes: \" .. table.concat(analysis.notes, \" \")\n    end\n\n    return table.concat({\n        \"I can answer questions about the current pattern's notes, step count, direction, leaps, chords, rests, rhythm, octaves, and lyrics.\",\n        \"Current pattern: \" .. table.concat(analysis.notes, \" \"),\n    }, \"\\n\")\nend\n\nlocal function setCurrentSolfegeTokens(tokens)\n    local text = joinTokens(tokens)\n    if _actions.setSolfegeText then\n        _actions.setSolfegeText(text)\n    elseif _actions.addSolfegeText then\n        _actions.addSolfegeText(text)\n    end\n    return text\nend\n\nlocal function parseIndexRange(raw, maxIndex, allowEnd)\n    raw = trim(raw)\n    if allowEnd and raw:lower() == \"end\" then\n        return maxIndex + 1, maxIndex + 1\n    end\n    local a, b = raw:match(\"^(%d+)%s*%-%s*(%d+)$\")\n    if not a then\n        a = raw:match(\"^(%d+)$\")\n        b = a\n    end\n    a, b = tonumber(a), tonumber(b)\n    if not a or not b then return nil end\n    if a > b then a, b = b, a end\n    return a, b\nend\n\nlocal function generatedPattern(kind, count)\n    kind = (kind or \"\"):lower()\n    count = math.max(1, math.min(32, math.floor(tonumber(count) or 8)))\n    local patterns = {\n        rise = {\"do\", \"re\", \"mi\", \"fa\", \"sol\", \"la\", \"ti\", \"do'\"},\n        up = {\"do\", \"re\", \"mi\", \"fa\", \"sol\", \"la\", \"ti\", \"do'\"},\n        fall = {\"do'\", \"ti\", \"la\", \"sol\", \"fa\", \"mi\", \"re\", \"do\"},\n        down = {\"do'\", \"ti\", \"la\", \"sol\", \"fa\", \"mi\", \"re\", \"do\"},\n        arch = {\"do\", \"re\", \"mi\", \"sol\", \"la\", \"sol\", \"mi\", \"re\"},\n        bass = {\"do\", \"sol\", \"do\", \"sol\", \"fa\", \"do\", \"sol\", \"do\"},\n        thirds = {\"do\", \"mi\", \"re\", \"fa\", \"mi\", \"sol\", \"fa\", \"la\"},\n        pent = {\"do\", \"re\", \"mi\", \"sol\", \"la\", \"sol\", \"mi\", \"re\"},\n    }\n    local source = patterns[kind]\n    if kind == \"random\" or kind == \"rand\" then\n        source = {\"do\", \"re\", \"mi\", \"fa\", \"sol\", \"la\", \"ti\"}\n    elseif not source then\n        return nil\n    end\n\n    local out = {}\n    for i = 1, count do\n        if kind == \"random\" or kind == \"rand\" then\n            out[i] = source[math.random(#source)]\n        else\n            out[i] = source[((i - 1) % #source) + 1]\n        end\n    end\n    return joinTokens(out)\nend\n\nlocal function applyRhythm(duration, text)\n    duration = trim(duration)\n    if duration == \"\" then return nil end\n    local tokens = splitSolfegeText(text)\n    if not tokens then return nil end\n    local out = {}\n    for i, token in ipairs(tokens) do\n        out[i] = applyTokenDuration(token, duration)\n    end\n    return joinTokens(out)\nend\n\nlocal DURATION_TO_BEATS = {\n    [\"1/1\"] = 4, [\"whole\"] = 4,\n    [\"1/2.\"] = 3, [\"dotted-half\"] = 3, [\"dottedhalf\"] = 3,\n    [\"1/2\"] = 2, [\"half\"] = 2,\n    [\"1/4.\"] = 1.5, [\"dotted-quarter\"] = 1.5, [\"dottedquarter\"] = 1.5,\n    [\"1/4\"] = 1, [\"quarter\"] = 1,\n    [\"1/8.\"] = 0.75, [\"dotted-eighth\"] = 0.75, [\"dottedeighth\"] = 0.75,\n    [\"1/8\"] = 0.5, [\"eighth\"] = 0.5,\n    [\"1/8t\"] = 1 / 3, [\"triplet\"] = 1 / 3,\n    [\"1/16\"] = 0.25, [\"sixteenth\"] = 0.25,\n    [\"1/32\"] = 0.125, [\"thirtysecond\"] = 0.125,\n}\n\nlocal function parseDurationBeats(raw)\n    local key = trim(raw):lower():gsub(\"%s+\", \"-\")\n    return DURATION_TO_BEATS[key] or tonumber(key)\nend\n\nlocal function applyTokenOctave(token, octave)\n    if isSolfegeChatChord(token) then\n        local prefix = token:sub(1, 1) == \"<\" and \"<\" or \"\"\n        local suffix = token:sub(-1) == \">\" and \">\" or \"\"\n        local body = token:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n        local out = {}\n        for part in body:gmatch(\"%S+\") do\n            out[#out + 1] = applyTokenOctave(part:gsub(\">$\", \"\"), octave)\n        end\n        return prefix .. table.concat(out, \" \") .. suffix\n    end\n    local corePart, lyric = token:match(\"^(.-)|(.+)$\")\n    corePart = corePart or token\n    local base, dur = corePart:match(\"^(.-)(/.*)$\")\n    base = base or corePart\n    dur = dur or \"\"\n    local name = base:match(\"^([A-Za-z][A-Za-z'%-]*)(%d*)$\")\n    if not name then return token end\n    local lower = name:lower()\n    if lower == \"rest\" or lower == \"r\" then return token end\n    return name .. tostring(octave) .. dur .. (lyric and (\"|\" .. lyric) or \"\")\nend\n\nlocal function setSolfegeTextResult(label, solfegeText)\n    if _actions.setSolfegeText then\n        _actions.setSolfegeText(solfegeText)\n    elseif _actions.addSolfegeText then\n        _actions.addSolfegeText(solfegeText)\n    end\n    return label .. \": \" .. solfegeText\nend\n\nlocal function saveSolfegeMelody(name, solfegeText)\n    if not _actions.saveSolfegeMelody then\n        return \"Save is not available\"\n    end\n    local savedName, savedText = _actions.saveSolfegeMelody(name, solfegeText)\n    if not savedName then\n        return \"Nothing to save. Type solfege first, or use: save do re mi as My Melody\"\n    end\n    if savedText and savedText ~= \"\" then\n        return \"Saved melody '\" .. savedName .. \"': \" .. savedText\n    end\n    return \"Saved melody '\" .. savedName .. \"'\"\nend\n\nlocal function getChatSolfegeTemplateCategories()\n    if _actions.getSolfegeTemplateCategories then\n        return _actions.getSolfegeTemplateCategories() or {}\n    end\n    return {}\nend\n\nlocal function textMatchesQuery(text, query)\n    query = trim(query):lower()\n    if query == \"\" then return true end\n    return tostring(text or \"\"):lower():find(query, 1, true) ~= nil\nend\n\nlocal function normalizeSolfegeTemplateToken(token)\n    local syllable = solfegeSyllableFromToken(token)\n    local lower = syllable:lower()\n    if lower == \"rest\" or lower == \"r\" then return \"--\" end\n    if SOLFEGE_NAME_TO_NOTE[lower] then return lower end\n    return nil\nend\n\nlocal function solfegeTemplateTokens(text)\n    local tokens = {}\n    for _, raw in ipairs(tokenizeSolfegeChatText(text)) do\n        if isSolfegeChatChord(raw) then\n            local chord = {}\n            local body = raw:gsub(\"^<\", \"\"):gsub(\">$\", \"\")\n            for part in body:gmatch(\"%S+\") do\n                local normalized = normalizeSolfegeTemplateToken(part)\n                if normalized then chord[#chord + 1] = normalized end\n            end\n            if #chord > 0 then tokens[#tokens + 1] = \"<\" .. table.concat(chord, \" \") .. \">\" end\n        else\n            local normalized = normalizeSolfegeTemplateToken(raw)\n            if normalized then\n                tokens[#tokens + 1] = normalized\n            end\n        end\n    end\n    return tokens\nend\n\nlocal function solfegeTemplateTextScore(templateText, query)\n    local queryTokens = solfegeTemplateTokens(query)\n    if #queryTokens == 0 then return nil end\n\n    local templateTokens = solfegeTemplateTokens(templateText)\n    if #templateTokens == 0 then return nil end\n\n    local exactMatch = #queryTokens == #templateTokens\n    for i = 1, #queryTokens do\n        if templateTokens[i] ~= queryTokens[i] then\n            exactMatch = false\n            break\n        end\n    end\n    if exactMatch then return 100 + #queryTokens end\n\n    if #queryTokens <= #templateTokens then\n        local prefixMatch = true\n        for i = 1, #queryTokens do\n            if templateTokens[i] ~= queryTokens[i] then\n                prefixMatch = false\n                break\n            end\n        end\n        if prefixMatch then return 80 + #queryTokens end\n\n        local bestRun = 0\n        for start = 1, #templateTokens - #queryTokens + 1 do\n            local run = 0\n            for i = 1, #queryTokens do\n                if templateTokens[start + i - 1] == queryTokens[i] then\n                    run = run + 1\n                else\n                    break\n                end\n            end\n            if run > bestRun then bestRun = run end\n            if run == #queryTokens then return 70 + #queryTokens end\n        end\n        if bestRun >= math.min(3, #queryTokens) then return 40 + bestRun end\n    end\n\n    local seen = {}\n    for _, token in ipairs(templateTokens) do seen[token] = true end\n    local overlap = 0\n    for _, token in ipairs(queryTokens) do\n        if seen[token] then overlap = overlap + 1 end\n    end\n    if overlap > 0 then return 10 + overlap end\n    return nil\nend\n\nlocal function sortTemplateMatches(matches)\n    table.sort(matches, function(a, b)\n        local scoreA = a.score or 0\n        local scoreB = b.score or 0\n        if scoreA ~= scoreB then return scoreA > scoreB end\n        if a.order ~= b.order then return (a.order or 0) < (b.order or 0) end\n        return tostring(a.name or \"\") < tostring(b.name or \"\")\n    end)\nend\n\nlocal function collectChatTemplateMatches(query)\n    local matches = {}\n    for _, cat in ipairs(getChatSolfegeTemplateCategories()) do\n        for _, t in ipairs(cat.templates or {}) do\n            local textScore = solfegeTemplateTextScore(t.text, query)\n            if textMatchesQuery(t.name, query) or textMatchesQuery(cat.name, query)\n               or textMatchesQuery(t.text, query) or textScore then\n                matches[#matches + 1] = {\n                    kind = \"solfege\",\n                    category = cat.name or \"Templates\",\n                    name = t.name or \"?\",\n                    text = t.text or \"\",\n                    score = textScore or 0,\n                    order = #matches + 1,\n                }\n            end\n        end\n    end\n    for i, t in ipairs(_state.userSolfegeTemplates or {}) do\n        local textScore = solfegeTemplateTextScore(t.text, query)\n        if textMatchesQuery(t.name, query) or textMatchesQuery(t.text, query) or textScore then\n            matches[#matches + 1] = {\n                kind = \"saved\",\n                category = \"My Templates\",\n                name = t.name or (\"Saved \" .. i),\n                text = t.text or \"\",\n                index = i,\n                score = textScore or 0,\n                order = #matches + 1,\n            }\n        end\n    end\n    sortTemplateMatches(matches)\n    return matches\nend\n\nlocal function findBestChatTemplate(query)\n    query = trim(query)\n    if query == \"\" then return nil, collectChatTemplateMatches(\"\") end\n    local matches = collectChatTemplateMatches(query)\n    local q = query:lower()\n    local best\n    for _, item in ipairs(matches) do\n        local name = (item.name or \"\"):lower()\n        if name == q then return item, matches end\n        if item.score and item.score >= 70 then return item, matches end\n        if not best and name:find(q, 1, true) then best = item end\n    end\n    return best or matches[1], matches\nend\n\nlocal function projectTemplatePreview(template, maxSteps)\n    if not template then return nil end\n    if template.texts and template.texts[1] then\n        return template.texts[1]\n    end\n    local sequenceIndex = 1\n    if template.sequenceStates and template.sequenceStates.activeIndex then\n        sequenceIndex = template.sequenceStates.activeIndex\n    end\n    local sequence = template.sequences and (template.sequences[sequenceIndex] or template.sequences[1])\n    if not sequence then return nil end\n    local solfegeNotes = _core.solfegeNotes or _core.getSolfegeNotes((_state and _state.solfegeScale) or \"major\")\n    local out = {}\n    local limit = maxSteps or 12\n    if template.sequenceLengths and template.sequenceLengths[sequenceIndex] and template.sequenceLengths[sequenceIndex] > 0 then\n        limit = math.min(limit, template.sequenceLengths[sequenceIndex])\n    else\n        limit = math.min(limit, #sequence)\n    end\n    for i = 1, limit do\n        local step = sequence[i]\n        local note = step and (step.note or (step.notes and step.notes[1] and step.notes[1].note))\n        out[#out + 1] = note ~= nil and (solfegeNotes[note + 1] or \"--\") or \"--\"\n    end\n    if #out == 0 then return nil end\n    return table.concat(out, \" \")\nend\n\nlocal function collectProjectTemplateMatches(query)\n    local matches = {}\n    if not _tl then return matches end\n    for _, catName in ipairs(_tl.categories or {}) do\n        for _, t in ipairs(_tl.getTemplatesByCategory(catName) or {}) do\n            local preview = projectTemplatePreview(t, 12)\n            local textScore = solfegeTemplateTextScore(preview, query)\n            if textMatchesQuery(t.name, query) or textMatchesQuery(t.description, query)\n               or textMatchesQuery(catName, query) or textScore then\n                matches[#matches + 1] = {\n                    kind = \"project\",\n                    category = catName,\n                    name = t.name or t.id or \"?\",\n                    description = t.description,\n                    template = t,\n                    text = preview,\n                    score = textScore or 0,\n                    order = #matches + 1,\n                }\n            end\n        end\n    end\n    sortTemplateMatches(matches)\n    return matches\nend\n\nlocal function findBestProjectTemplate(query)\n    query = trim(query)\n    local matches = collectProjectTemplateMatches(query)\n    local q = query:lower()\n    for _, item in ipairs(matches) do\n        if (item.name or \"\"):lower() == q then return item, matches end\n    end\n    return matches[1], matches\nend\n\nlocal function formatTemplateMatches(title, matches, maxItems)\n    maxItems = maxItems or 12\n    if #matches == 0 then return title .. \":\\n  None\" end\n    local lines = {title .. \":\"}\n    for i = 1, math.min(#matches, maxItems) do\n        local item = matches[i]\n        local suffix = item.text and item.text ~= \"\" and (\" - \" .. item.text) or \"\"\n        lines[#lines + 1] = \"  \" .. i .. \". \" .. item.name .. \" (\" .. item.category .. \")\" .. suffix\n    end\n    if #matches > maxItems then\n        lines[#lines + 1] = \"  +\" .. (#matches - maxItems) .. \" more. Use: templates find <name>\"\n    end\n    return table.concat(lines, \"\\n\")\nend\n\nlocal function applyChatTemplate(item, mode)\n    if not item then return \"No template selected\" end\n    local text = item.text or \"\"\n    if text == \"\" then return \"Template has no solfege text preview\" end\n    mode = (mode or \"add\"):lower()\n    if mode == \"set\" or mode == \"replace\" then\n        if _actions.setSolfegeText then _actions.setSolfegeText(text) end\n        return \"Set template: \" .. item.name .. \" - \" .. text\n    elseif mode == \"play\" or mode == \"hear\" then\n        if _actions.playSolfegeText then _actions.playSolfegeText(text) end\n        return \"Played template: \" .. item.name .. \" - \" .. text\n    elseif mode == \"loop\" then\n        if _actions.loopSolfegeText then _actions.loopSolfegeText(text) end\n        return \"Looping template: \" .. item.name .. \" - \" .. text\n    end\n    if _actions.addSolfegeText then _actions.addSolfegeText(text) end\n    return \"Added template: \" .. item.name .. \" - \" .. text\nend\n\naddCommand(\"help\", \"help [command]\", \"Show available commands or detail for one\", function(args)\n    args = trim(args)\n    if #args > 0 then\n        local requested = args:lower()\n        if requested == \"solfege\" or requested == \"notes\" then\n            return table.concat({\n                \"Solfege chat:\",\n                \"  do re mi      Add and play\",\n                \"  add do re mi  Add to text box\",\n                \"  chord do mi sol Add chord stack\",\n                \"  set do re mi  Replace text box\",\n                \"  play do re mi Play once\",\n                \"  play major    Play a scale up & down\",\n                \"  play major then minor\",\n                \"  hear blues scale\",\n                \"  loop do re mi Loop until stopped\",\n                \"  live do re mi Replace and loop\",\n                \"  save melody Riff 1\",\n                \"  save do re mi as Riff 1\",\n                \"  drone on/off  Toggle solfege drone\",\n                \"  lyrics Kyrie eleison\",\n                \"  lyrics add Christe eleison\",\n                \"  replace 2 mi\",\n                \"  insert 3 fa sol\",\n                \"  delete 4-5\",\n                \"  change mi to me\",\n                \"  change 3 up\",\n                \"  swap do sol\",\n                \"  repeat 4 do   Expand phrase\",\n                \"  make arch 8   Generate phrase\",\n                \"  template Major Triad\",\n                \"  template play Blues\",\n                \"  templates find minor\",\n                \"  ask what notes are in this pattern\",\n                \"  reverse do re mi\",\n                \"  rhythm 1/8 do re mi\",\n                \"  loop on/off   Toggle playback loop\",\n                \"  clear all     Clear text, lyrics, and steps\",\n            }, \"\\n\")\n        end\n        local name = resolveCommandName(requested)\n        local entry = commands[name]\n        if entry then\n            local aliasList = {}\n            for alias, target in pairs(aliases) do\n                if target == name then aliasList[#aliasList + 1] = alias end\n            end\n            table.sort(aliasList)\n            local lines = {entry.usage, entry.description}\n            if #aliasList > 0 then\n                lines[#lines + 1] = \"Aliases: \" .. table.concat(aliasList, \", \")\n            end\n            return table.concat(lines, \"\\n\")\n        end\n        return \"Unknown command: \" .. args\n    end\n    local lines = {\n        \"Try:\",\n        \"  do re mi      Add and play\",\n        \"  add do re mi  Add to text box\",\n        \"  chord do mi sol Add chord stack\",\n        \"  set do re mi  Replace text box\",\n        \"  play do re mi Play once\",\n        \"  play major    Play a scale\",\n        \"  play major then minor\",\n        \"  loop do re mi Loop\",\n        \"  live do re mi Replace and loop\",\n        \"  make arch 8   Generate phrase\",\n        \"  template Major Triad\",\n        \"  templates find minor\",\n        \"  ask about the current pattern\",\n        \"  save melody Riff 1\",\n        \"  drone on/off  Toggle solfege drone\",\n        \"  lyrics Kyrie eleison\",\n        \"  replace 2 mi\",\n        \"  insert 3 fa sol\",\n        \"  delete 4-5\",\n        \"  change mi to me\",\n        \"  change 3 up\",\n        \"  swap do sol\",\n        \"  undo / redo\",\n        \"  loop on/off   Toggle playback loop\",\n        \"  clear all     Clear text, lyrics, and steps\",\n        \"\",\n        \"Commands:\",\n    }\n    for _, name in ipairs(commandOrder) do\n        local entry = commands[name]\n        if entry and not entry.hidden then\n            lines[#lines+1] = \"  \" .. name .. \" - \" .. entry.description\n        end\n    end\n    return table.concat(lines, \"\\n\")\nend)\n\naddCommand(\"info\", \"info\", \"Show current state\", function()\n    local s = _state\n    local keyName = NOTE_DISPLAY[(s.keyNote or 0) + 1] or \"C\"\n    local scaleName = s.solfegeScale or s.scaleMode or \"major\"\n    local playing = s.isPlaying and \"Playing\" or \"Stopped\"\n    local seqLen = s.sequenceLength or 0\n    local loopState = s.loopPlayback and \"On\" or \"Off\"\n    local droneState = s.droneEnabled and (\"On \" .. getSolfegeDisplayName(s.droneNoteSelection or 0) .. tostring(s.droneOctave or 4)) or \"Off\"\n    local defaultLength\n    if _core and _core.getStepBeatsShortLabel then\n        defaultLength = _core.getStepBeatsShortLabel(s.stepBeats or 1)\n    else\n        defaultLength = tostring(s.stepBeats or 1)\n    end\n    local lines = {\n        playing .. \" | Tempo: \" .. (s.tempo or s.defaultTempo or 120) .. \" BPM\",\n        \"Key: \" .. keyName .. \" | Scale: \" .. scaleName,\n        \"Loop: \" .. loopState .. \" | Octave: \" .. (s.currentOctave or s.keyOctave or 4),\n        \"Drone: \" .. droneState,\n        \"Default: \" .. defaultLength .. \" | Steps: \" .. seqLen,\n        \"Mode: \" .. (s.solfegeTextMode or \"both\"),\n    }\n    if s.stepLoopStart and s.stepLoopEnd then\n        lines[#lines+1] = \"Loop: \" .. s.stepLoopStart .. \"-\" .. s.stepLoopEnd\n    end\n    return table.concat(lines, \"\\n\")\nend)\n\naddCommand(\"ask\", \"ask <question about current pattern>\", \"Answer questions about the current solfege pattern\", function(args)\n    args = trim(args)\n    if args == \"\" then\n        return answerPatternQuestion(\"summary\")\n    end\n    return answerPatternQuestion(args)\nend)\n\naddCommand(\"view\", \"view <note-lengths|octaves> [show|hide|toggle]\", \"Show or hide notation details\", function(args)\n    args = trim(args)\n    local targetRaw, stateRaw = args:match(\"^(%S+)%s*(.*)$\")\n    targetRaw = (targetRaw or \"\"):lower():gsub(\"_\", \"-\")\n    stateRaw = trim(stateRaw or \"\")\n\n    local key, label\n    if targetRaw == \"length\" or targetRaw == \"lengths\" or targetRaw == \"note-length\" or targetRaw == \"note-lengths\" then\n        key = \"showNoteLengths\"\n        label = \"Note lengths\"\n    elseif targetRaw == \"octave\" or targetRaw == \"octaves\" or targetRaw == \"octave-number\" or targetRaw == \"octave-numbers\" then\n        key = \"showOctaveNumbers\"\n        label = \"Octave numbers\"\n    else\n        return \"Usage: view <note-lengths|octaves> [show|hide|toggle]\"\n    end\n\n    local desired, err = parseToggleState(stateRaw)\n    if err then return err end\n    if desired == nil then\n        desired = not (_state[key] ~= false)\n    end\n    _state[key] = desired\n    if _actions.savePreferences then _actions.savePreferences() end\n    return label .. \": \" .. (desired and \"shown\" or \"hidden\")\nend)\n\naddCommand(\"undo\", \"undo\", \"Undo the last text or step edit\", function()\n    if not _actions.undoEdit then return \"Undo is not available\" end\n    local result = _actions.undoEdit()\n    return result or \"Nothing to undo\"\nend)\n\naddCommand(\"redo\", \"redo\", \"Redo the last text or step edit\", function()\n    if not _actions.redoEdit then return \"Redo is not available\" end\n    local result = _actions.redoEdit()\n    return result or \"Nothing to redo\"\nend)\n\naddCommand(\"bpm\", \"bpm <30-300>\", \"Set tempo\", function(args)\n    local n = tonumber(trim(args))\n    if not n then return \"Usage: bpm <number>\" end\n    n = math.max(30, math.min(300, math.floor(n)))\n    if _actions.setTempo then _actions.setTempo(n) end\n    return \"Tempo set to \" .. n .. \" BPM\"\nend)\n\nlocal function adjustTempoBy(delta)\n    local current = tonumber(_state.tempo or _state.defaultTempo) or 120\n    local nextTempo = math.max(30, math.min(300, math.floor(current + delta)))\n    if _actions.setTempo then _actions.setTempo(nextTempo) end\n    return nextTempo\nend\n\naddCommand(\"faster\", \"faster [amount]\", \"Increase tempo\", function(args)\n    local amount = tonumber(trim(args)) or 10\n    amount = math.max(1, math.min(100, math.floor(math.abs(amount))))\n    local nextTempo = adjustTempoBy(amount)\n    return \"Faster: \" .. nextTempo .. \" BPM\"\nend)\n\naddCommand(\"slower\", \"slower [amount]\", \"Decrease tempo\", function(args)\n    local amount = tonumber(trim(args)) or 10\n    amount = math.max(1, math.min(100, math.floor(math.abs(amount))))\n    local nextTempo = adjustTempoBy(-amount)\n    return \"Slower: \" .. nextTempo .. \" BPM\"\nend)\n\naddCommand(\"play\", \"play [do re mi... | major | minor | ...]\", \"Start playback, play solfege, or play a scale\", function(args)\n    args = trim(args)\n    if #args > 0 then\n        if args:lower() == \"this\" or args:lower() == \"current\" or args:lower() == \"melody\" then\n            local solfegeText, err = getCurrentSolfegeText()\n            if not solfegeText then return err end\n            if _actions.playSolfegeText then\n                local ok, playErr = _actions.playSolfegeText(solfegeText, _state.loopPlayback == true, true)\n                if ok == false then return playErr or \"Nothing to play\" end\n            end\n            return \"Played: \" .. solfegeText\n        end\n        local scalePhrase, scaleLabel = buildScalePhrase(args)\n        if scalePhrase then\n            local result = handleSolfegeText(scalePhrase, true, false, true)\n            if result then return \"Played \" .. scaleLabel .. \" scale: \" .. scalePhrase end\n        end\n        local result = handleSolfegeText(args, true, false, true)\n        return result or \"Usage: play [do re mi... | major | minor | ...]\\nScales: \" .. scaleNamesListText()\n    end\n    local resumedSolfegeLoop = _actions.resumeSolfegeLoop and _actions.resumeSolfegeLoop() == true\n    if _state.isPaused and _actions.startPlayback then\n        local ok, err = _actions.startPlayback()\n        if not ok then\n            return err or \"Nothing to resume\"\n        end\n        return resumedSolfegeLoop and \"Playback and solfege loop resumed\" or \"Playback resumed\"\n    end\n    if resumedSolfegeLoop then\n        return \"Solfege playback resumed\"\n    end\n    if _state.isPlaying then return \"Already playing\" end\n    if _actions.startPlayback then\n        local ok, err = _actions.startPlayback()\n        if not ok then\n            return err or \"Nothing to play. Type solfege like 'do re mi' first.\"\n        end\n    end\n    return \"Playback started\"\nend)\n\nlocal function setDroneCommand(args, forcedEnabled)\n    args = trim(args)\n    local lower = args:lower()\n    local enabled = forcedEnabled\n    local noteIndex\n    local octave\n\n    if lower == \"\" and enabled == nil then\n        enabled = not (_state.droneEnabled == true)\n    elseif lower == \"on\" or lower == \"start\" or lower == \"add\" or lower == \"play\" then\n        enabled = true\n    elseif lower == \"off\" or lower == \"stop\" or lower == \"remove\" or lower == \"mute\" then\n        enabled = false\n    else\n        local maybeOctave = lower:match(\"^octave%s+(%d+)$\") or lower:match(\"^oct%s+(%d+)$\")\n        if maybeOctave then\n            octave = math.max(2, math.min(7, math.floor(tonumber(maybeOctave) or 4)))\n            enabled = true\n        else\n            local noteText, octaveText = lower:match(\"^(.-)%s+octave%s+(%d+)$\")\n            if noteText then\n                noteIndex = parseDroneNote(noteText)\n                octave = math.max(2, math.min(7, math.floor(tonumber(octaveText) or 4)))\n            else\n                noteIndex = parseDroneNote(lower)\n            end\n            if noteIndex == nil then\n                return \"Usage: drone [on|off|do|re|mi|fa|sol|la|ti|octave 2-7]\"\n            end\n            enabled = true\n        end\n    end\n\n    if _actions.setDrone then\n        _actions.setDrone({\n            enabled = enabled,\n            note = noteIndex,\n            octave = octave,\n        })\n    else\n        _state.droneEnabled = enabled == true\n        if noteIndex ~= nil then _state.droneNoteSelection = noteIndex end\n        if octave ~= nil then _state.droneOctave = octave end\n        if _actions.savePreferences then _actions.savePreferences() end\n    end\n\n    if enabled then\n        local noteName = getSolfegeDisplayName(_state.droneNoteSelection or noteIndex or 0)\n        return \"Drone on: \" .. noteName .. tostring(_state.droneOctave or octave or 4)\n    end\n    return \"Drone off\"\nend\n\naddCommand(\"drone\", \"drone [on|off|do|re|mi|fa|sol|la|ti|octave 2-7]\", \"Control the solfege drone\", function(args)\n    return setDroneCommand(args)\nend)\n\naddCommand(\"stop\", \"stop [drone]\", \"Stop playback or the drone\", function(args)\n    if trim(args):lower() == \"drone\" then\n        return setDroneCommand(\"off\", false)\n    end\n    local stoppedSolfegeLoop = false\n    if _actions.stopSolfegeLoop then\n        stoppedSolfegeLoop = _actions.stopSolfegeLoop() == true\n    end\n    if not _state.isPlaying then\n        return stoppedSolfegeLoop and \"Solfege loop stopped\" or \"Already stopped\"\n    end\n    if _actions.stopPlayback then _actions.stopPlayback() end\n    if stoppedSolfegeLoop then\n        return \"Playback and solfege loop stopped\"\n    end\n    return \"Playback stopped\"\nend)\n\nlocal function setChatMuteCommand(args, forced)\n    args = trim(args)\n    local lowerArgs = args:lower()\n    local muted\n    if forced ~= nil then\n        muted = forced\n    elseif args == \"\" or lowerArgs == \"toggle\" then\n        muted = not (_state.cmdChatMuted == true)\n    elseif isOneOf(lowerArgs, {\"on\", \"true\", \"yes\", \"1\"}) then\n        muted = true\n    elseif isOneOf(lowerArgs, {\"off\", \"false\", \"no\", \"0\"}) then\n        muted = false\n    else\n        return \"Usage: mute [on|off]\"\n    end\n\n    if _actions.setChatMuted then\n        _actions.setChatMuted(muted)\n    else\n        _state.cmdChatMuted = muted\n    end\n    return muted and \"Chat muted\" or \"Chat unmuted\"\nend\n\naddCommand(\"mute\", \"mute [on|off]\", \"Mute chat audition sounds\", function(args)\n    return setChatMuteCommand(args)\nend)\n\naddCommand(\"unmute\", \"unmute\", \"Unmute chat audition sounds\", function()\n    return setChatMuteCommand(\"\", false)\nend)\n\nlocal function formatVolumePercent(volume)\n    return tostring(math.floor((volume or 0) * 100 + 0.5)) .. \"%\"\nend\n\nlocal function parseVolumeValue(args)\n    local text = trim(args):lower()\n    if text == \"\" then\n        return nil\n    end\n\n    text = text:gsub(\"^to%s+\", \"\")\n    text = text:gsub(\"^at%s+\", \"\")\n    text = text:gsub(\"^set%s+\", \"\")\n    text = trim(text)\n\n    local rawNumber = text:match(\"^(%d+%.?%d*)%s*%%?$\")\n    if not rawNumber then\n        return nil, \"Usage: volume <0-100%|up|down>\"\n    end\n\n    local value = tonumber(rawNumber)\n    if not value then\n        return nil, \"Usage: volume <0-100%|up|down>\"\n    end\n\n    if text:find(\"%%\") or value > 1 then\n        value = value / 100\n    end\n    return math.max(0, math.min(1, value))\nend\n\nlocal function parseVolumeStep(rawAmount)\n    local amount = trim(rawAmount or \"\")\n    if amount == \"\" then\n        return 0.1\n    end\n\n    local value, err = parseVolumeValue(amount)\n    if err then return nil, err end\n    return value\nend\n\naddCommand(\"volume\", \"volume [0-100%|up|down]\", \"Show or set master volume\", function(args)\n    local text = trim(args):lower()\n    local direction, amount = text:match(\"^(up)%s*(.*)$\")\n    if not direction then\n        direction, amount = text:match(\"^(down)%s*(.*)$\")\n    end\n    if not direction then\n        direction, amount = text:match(\"^(increase)%s*(.*)$\")\n    end\n    if not direction then\n        direction, amount = text:match(\"^(decrease)%s*(.*)$\")\n    end\n\n    local volume, err\n    if direction then\n        local step\n        step, err = parseVolumeStep(amount)\n        if err then return err end\n        local current = math.max(0, math.min(1, tonumber(_state.masterVolume) or 1))\n        if direction == \"up\" or direction == \"increase\" then\n            volume = math.min(1, current + step)\n        else\n            volume = math.max(0, current - step)\n        end\n    else\n        volume, err = parseVolumeValue(args)\n    end\n    if err then return err end\n\n    if volume == nil then\n        return \"Volume: \" .. formatVolumePercent(_state.masterVolume or 1)\n    end\n\n    if _actions.setMasterVolume then\n        _actions.setMasterVolume(volume)\n    else\n        _state.masterVolume = volume\n        if _actions.savePreferences then _actions.savePreferences() end\n    end\n    return \"Volume set to \" .. formatVolumePercent(volume)\nend)\n\naddCommand(\"hear\", \"hear <do re mi... | major | minor | ...>\", \"Play solfege syllables or a scale without adding them\", function(args)\n    args = trim(args)\n    if args:lower() == \"this\" or args:lower() == \"current\" or args:lower() == \"melody\" then\n        local solfegeText, err = getCurrentSolfegeText()\n        if not solfegeText then return err end\n        if _actions.playSolfegeText then\n            local ok, playErr = _actions.playSolfegeText(solfegeText, _state.loopPlayback == true, true)\n            if ok == false then return playErr or \"Nothing to play\" end\n        end\n        return \"Played: \" .. solfegeText\n    end\n    local scalePhrase, scaleLabel = buildScalePhrase(args)\n    if scalePhrase then\n        local result = handleSolfegeText(scalePhrase, true, false, true)\n        if result then return \"Played \" .. scaleLabel .. \" scale: \" .. scalePhrase end\n    end\n    local result = handleSolfegeText(args, true, false, true)\n    return result or \"Usage: hear <do re mi... | major | minor | ...>\\nScales: \" .. scaleNamesListText()\nend)\n\naddCommand(\"add\", \"add <do re mi...>\", \"Add solfege syllables to the text box\", function(args)\n    if trim(args):lower() == \"drone\" then\n        return setDroneCommand(\"on\", true)\n    end\n    local result = handleSolfegeText(args, false, true)\n    return result or \"Usage: add <do re mi...>\"\nend)\n\naddCommand(\"set\", \"set <do re mi...>\", \"Replace the text box with solfege syllables\", function(args)\n    local solfegeText, err = solfegeTextOrUsage(args, \"Usage: set <do re mi...>\")\n    if not solfegeText then return err end\n    if _actions.setSolfegeText then\n        _actions.setSolfegeText(solfegeText)\n    elseif _actions.addSolfegeText then\n        _actions.addSolfegeText(solfegeText)\n    end\n    return \"Set: \" .. solfegeText\nend)\n\naddCommand(\"live\", \"live <do re mi...>\", \"Replace the text box and loop solfege syllables\", function(args)\n    local solfegeText, err = solfegeTextOrUsage(args, \"Usage: live <do re mi...>\")\n    if not solfegeText then return err end\n    if _actions.setSolfegeText then\n        _actions.setSolfegeText(solfegeText)\n    end\n    if _actions.loopSolfegeText then\n        _actions.loopSolfegeText(solfegeText)\n    elseif _actions.playSolfegeText then\n        _actions.playSolfegeText(solfegeText)\n    end\n    return \"Live loop: \" .. solfegeText\nend)\n\naddCommand(\"chord\", \"chord [add|set|play|loop] <do mi sol>\", \"Create a chord stack from solfege syllables\", function(args)\n    args = trim(args)\n    if args == \"\" then return \"Usage: chord [add|set|play|loop] <do mi sol>\" end\n\n    local first, rest = args:match(\"^(%S+)%s+(.+)$\")\n    local mode = (first or \"\"):lower()\n    if mode ~= \"add\" and mode ~= \"set\" and mode ~= \"replace\" and mode ~= \"play\"\n       and mode ~= \"hear\" and mode ~= \"loop\" and mode ~= \"live\" then\n        mode = \"add\"\n        rest = args\n    end\n\n    local chordText = chordTextFromPhrase(rest or \"\")\n    if not chordText then return \"Usage: chord [add|set|play|loop] <do mi sol>\" end\n\n    if mode == \"set\" or mode == \"replace\" then\n        if _actions.setSolfegeText then\n            _actions.setSolfegeText(chordText)\n        elseif _actions.addSolfegeText then\n            _actions.addSolfegeText(chordText)\n        end\n        return \"Set chord: \" .. chordText\n    elseif mode == \"play\" or mode == \"hear\" then\n        if _actions.playSolfegeText then\n            local ok, err = _actions.playSolfegeText(chordText, _state.loopPlayback == true, true)\n            if ok == false then return err or \"Nothing to play\" end\n        end\n        return \"Played chord: \" .. chordText\n    elseif mode == \"loop\" or mode == \"live\" then\n        if _actions.loopSolfegeText then\n            _actions.loopSolfegeText(chordText, true)\n        elseif _actions.playSolfegeText then\n            _actions.playSolfegeText(chordText, false, true)\n        end\n        return \"Looping chord: \" .. chordText\n    end\n\n    if _actions.addSolfegeText then\n        _actions.addSolfegeText(chordText)\n    end\n    return \"Added chord: \" .. chordText\nend)\n\naddCommand(\"lyrics\", \"lyrics <text> | lyrics add <text> | lyrics notes <text> | lyrics clear\", \"Set, add, or import lyric text\", function(args)\n    args = trim(args)\n    if args == \"\" then\n        return \"Usage: lyrics <text> | lyrics add <text> | lyrics notes <text> | lyrics clear\"\n    end\n    local sub, rest = args:match(\"^(%S+)%s*(.*)$\")\n    local lowerSub = (sub or \"\"):lower()\n    if lowerSub == \"clear\" then\n        if _actions.clearLyrics then _actions.clearLyrics() end\n        return \"Lyrics cleared\"\n    elseif lowerSub == \"add\" or lowerSub == \"append\" then\n        rest = trim(rest)\n        if rest == \"\" then return \"Usage: lyrics add <text>\" end\n        if _actions.addLyricsText then\n            _actions.addLyricsText(rest)\n        elseif _actions.setLyricsText then\n            _actions.setLyricsText(rest)\n        end\n        return \"Added lyrics: \" .. rest\n    elseif lowerSub == \"notes\" or lowerSub == \"note\" or lowerSub == \"scratch\" then\n        rest = trim(rest)\n        if rest == \"\" then return \"Usage: lyrics notes <text>\" end\n        if _actions.setLyricNotesText then\n            _actions.setLyricNotesText(rest)\n        end\n        return \"Lyric notes: \" .. rest\n    elseif lowerSub == \"import\" or lowerSub == \"apply\" then\n        rest = trim(rest)\n        if rest == \"\" then return \"Usage: lyrics import <text>\" end\n        if _actions.importLyricsText and _actions.importLyricsText(rest) then\n            return \"Imported lyrics: \" .. rest\n        end\n        return \"No lyric words found\"\n    end\n    if _actions.setLyricsText then\n        _actions.setLyricsText(args)\n    elseif _actions.importLyricsText then\n        _actions.importLyricsText(args)\n    end\n    return \"Set lyrics: \" .. args\nend)\n\naddCommand(\"repeat\", \"repeat <1-32> <do re mi...>\", \"Repeat a solfege phrase into the text box\", function(args)\n    local solfegeText, err = repeatSolfegeText(args)\n    if not solfegeText then return err end\n    if _actions.setSolfegeText then\n        _actions.setSolfegeText(solfegeText)\n    elseif _actions.addSolfegeText then\n        _actions.addSolfegeText(solfegeText)\n    end\n    return \"Repeated: \" .. solfegeText\nend)\n\naddCommand(\"make\", \"make <rise|fall|arch|bass|thirds|pent|random> [1-32]\", \"Generate a solfege phrase\", function(args)\n    local kind, count = trim(args):match(\"^(%S+)%s*(%d*)$\")\n    local solfegeText = generatedPattern(kind, count)\n    if not solfegeText then\n        return \"Usage: make <rise|fall|arch|bass|thirds|pent|random> [1-32]\"\n    end\n    return setSolfegeTextResult(\"Made\", solfegeText)\nend)\n\naddCommand(\"reverse\", \"reverse <do re mi...>\", \"Reverse a solfege phrase\", function(args)\n    args = trim(args)\n    local tokens\n    if args == \"\" or args:lower() == \"this\" or args:lower() == \"current\" then\n        local err\n        tokens, err = getCurrentSolfegeTokens()\n        if not tokens then return err end\n    else\n        tokens = splitSolfegeText(args)\n    end\n    if not tokens then return \"Usage: reverse <do re mi...>\" end\n    local out = {}\n    for i = #tokens, 1, -1 do out[#out + 1] = tokens[i] end\n    return setSolfegeTextResult(\"Reversed\", joinTokens(out))\nend)\n\naddCommand(\"rotate\", \"rotate <steps> <do re mi...>\", \"Rotate a solfege phrase\", function(args)\n    local rawSteps, phrase = trim(args):match(\"^([%-]?%d+)%s+(.+)$\")\n    local tokens = splitSolfegeText(phrase or \"\")\n    local steps = tonumber(rawSteps)\n    if not steps or not tokens then return \"Usage: rotate <steps> <do re mi...>\" end\n    local out = {}\n    local n = #tokens\n    local shift = ((steps % n) + n) % n\n    for i = 1, n do\n        out[i] = tokens[((i - shift - 1) % n) + 1]\n    end\n    return setSolfegeTextResult(\"Rotated\", joinTokens(out))\nend)\n\naddCommand(\"rhythm\", \"rhythm <duration> <do re mi...>\", \"Apply one duration to a solfege phrase\", function(args)\n    args = trim(args)\n    local duration, phrase = args:match(\"^(%S+)%s+(.+)$\")\n    if duration and (phrase:lower() == \"this\" or phrase:lower() == \"current\") then\n        local current, err = getCurrentSolfegeTokens()\n        if not current then return err end\n        phrase = joinTokens(current)\n    elseif not duration and args ~= \"\" then\n        duration = args\n        local current, err = getCurrentSolfegeTokens()\n        if not current then return err end\n        phrase = joinTokens(current)\n    end\n    local solfegeText = duration and applyRhythm(duration, phrase)\n    if not solfegeText then return \"Usage: rhythm <duration> <do re mi...>\" end\n    return setSolfegeTextResult(\"Rhythm\", solfegeText)\nend)\n\naddCommand(\"length\", \"length <duration> | length <step|start-end> <duration>\", \"Set default or step note length\", function(args)\n    args = trim(args)\n    local a, b = args:match(\"^(%S+)%s+(%S+)$\")\n    if a and b then\n        local beats = parseDurationBeats(b)\n        local current, err = getCurrentSolfegeTokens()\n        if not current then return err end\n        local startIdx, endIdx = parseIndexRange(a, #current, false)\n        if not startIdx or not beats then return \"Usage: length <step|start-end> <duration>\" end\n        if startIdx < 1 or endIdx > #current then\n            return \"Step out of range. Current length: \" .. #current\n        end\n        local suffix = b:gsub(\"^1/\", \"\")\n        for i = startIdx, endIdx do\n            current[i] = applyTokenDuration(current[i], suffix)\n        end\n        local text = setCurrentSolfegeTokens(current)\n        return \"Length \" .. startIdx .. (endIdx ~= startIdx and (\"-\" .. endIdx) or \"\") .. \" set to \" .. b .. \": \" .. text\n    end\n\n    local beats = parseDurationBeats(args)\n    if not beats then return \"Usage: length <duration> | length <step|start-end> <duration>\" end\n    if _actions.setStepLength then\n        _actions.setStepLength(beats)\n    end\n    return \"Default note length: \" .. args\nend)\n\naddCommand(\"vary\", \"vary <do re mi...>\", \"Create a phrase plus its reverse\", function(args)\n    local tokens = splitSolfegeText(args)\n    if not tokens then return \"Usage: vary <do re mi...>\" end\n    local out = {}\n    for i = 1, #tokens do out[#out + 1] = tokens[i] end\n    for i = #tokens, 1, -1 do out[#out + 1] = tokens[i] end\n    return setSolfegeTextResult(\"Variation\", joinTokens(out))\nend)\n\naddCommand(\"replace\", \"replace <step|start-end> <do re mi...>\", \"Replace solfege syllables at step positions\", function(args)\n    local rangeRaw, phrase = trim(args):match(\"^(%S+)%s+(.+)$\")\n    local current, err = getCurrentSolfegeTokens()\n    if not current then return err end\n    local startIdx, endIdx = parseIndexRange(rangeRaw or \"\", #current, false)\n    local replacement = splitSolfegeText(phrase or \"\")\n    if not startIdx or not replacement then\n        return \"Usage: replace <step|start-end> <do re mi...>\"\n    end\n    if startIdx < 1 or startIdx > #current or endIdx > #current then\n        return \"Step out of range. Current length: \" .. #current\n    end\n    local out = {}\n    for i = 1, startIdx - 1 do out[#out + 1] = current[i] end\n    for _, token in ipairs(replacement) do out[#out + 1] = token end\n    for i = endIdx + 1, #current do out[#out + 1] = current[i] end\n    local text = setCurrentSolfegeTokens(out)\n    return \"Replaced \" .. startIdx .. (endIdx ~= startIdx and (\"-\" .. endIdx) or \"\") .. \": \" .. text\nend)\n\naddCommand(\"insert\", \"insert <step|end> <do re mi...>\", \"Insert solfege syllables before a step\", function(args)\n    local indexRaw, phrase = trim(args):match(\"^(%S+)%s+(.+)$\")\n    local current, err = getCurrentSolfegeTokens()\n    if not current then return err end\n    local insertAt = parseIndexRange(indexRaw or \"\", #current, true)\n    local inserted = splitSolfegeText(phrase or \"\")\n    if not insertAt or not inserted then\n        return \"Usage: insert <step|end> <do re mi...>\"\n    end\n    if insertAt < 1 or insertAt > #current + 1 then\n        return \"Insert position out of range. Use 1-\" .. (#current + 1) .. \" or end\"\n    end\n    local out = {}\n    for i = 1, insertAt - 1 do out[#out + 1] = current[i] end\n    for _, token in ipairs(inserted) do out[#out + 1] = token end\n    for i = insertAt, #current do out[#out + 1] = current[i] end\n    local text = setCurrentSolfegeTokens(out)\n    return \"Inserted at \" .. insertAt .. \": \" .. text\nend)\n\naddCommand(\"delete\", \"delete <step|start-end>\", \"Delete solfege syllables from the sequence\", function(args)\n    local current, err = getCurrentSolfegeTokens()\n    if not current then return err end\n    local startIdx, endIdx = parseIndexRange(args, #current, false)\n    if not startIdx then return \"Usage: delete <step|start-end>\" end\n    if startIdx < 1 or startIdx > #current or endIdx > #current then\n        return \"Step out of range. Current length: \" .. #current\n    end\n    local out = {}\n    for i = 1, #current do\n        if i < startIdx or i > endIdx then out[#out + 1] = current[i] end\n    end\n    local text = setCurrentSolfegeTokens(out)\n    return \"Deleted \" .. startIdx .. (endIdx ~= startIdx and (\"-\" .. endIdx) or \"\") .. \": \" .. text\nend)\n\naddCommand(\"change\", \"change <from> to <to> | change <step|start-end> up|down [N]\", \"Edit syllables across the current pattern\", function(args)\n    args = trim(args)\n    local fromRaw, toRaw = args:match(\"^(%S+)%s+to%s+(%S+)$\")\n    if fromRaw and toRaw then\n        local from = canonicalSolfegeSyllable(fromRaw)\n        local to = canonicalSolfegeSyllable(toRaw)\n        if not from or not to then return \"Usage: change <from> to <to>\" end\n        local current, err = getCurrentSolfegeTokens()\n        if not current then return err end\n        local changed = 0\n        for i, token in ipairs(current) do\n            local nextToken, didChange = replaceTokenSyllable(token, from, to)\n            current[i] = nextToken\n            if didChange then changed = changed + 1 end\n        end\n        if changed == 0 then return \"No \" .. from .. \" syllables found\" end\n        local text = setCurrentSolfegeTokens(current)\n        return \"Changed \" .. from .. \" to \" .. to .. \" (\" .. changed .. \"): \" .. text\n    end\n\n    local rangeRaw, directionRaw, amountRaw = args:match(\"^(%S+)%s+(up)%s*(%d*)$\")\n    if not rangeRaw then\n        rangeRaw, directionRaw, amountRaw = args:match(\"^(%S+)%s+(down)%s*(%d*)$\")\n    end\n    if rangeRaw and directionRaw then\n        local current, err = getCurrentSolfegeTokens()\n        if not current then return err end\n        local startIdx, endIdx = parseIndexRange(rangeRaw, #current, false)\n        if not startIdx then return \"Usage: change <step|start-end> up|down [N]\" end\n        if startIdx < 1 or startIdx > #current or endIdx > #current then\n            return \"Step out of range. Current length: \" .. #current\n        end\n        local amount = math.max(1, math.min(24, math.floor(tonumber(amountRaw) or 1)))\n        local steps = directionRaw == \"up\" and amount or -amount\n        local changed = 0\n        for i = startIdx, endIdx do\n            local nextToken, didChange = shiftTokenSyllable(current[i], steps)\n            current[i] = nextToken\n            if didChange then changed = changed + 1 end\n        end\n        if changed == 0 then return \"No editable syllables in \" .. startIdx .. (endIdx ~= startIdx and (\"-\" .. endIdx) or \"\") end\n        local text = setCurrentSolfegeTokens(current)\n        return \"Changed \" .. startIdx .. (endIdx ~= startIdx and (\"-\" .. endIdx) or \"\") .. \" \" .. directionRaw .. \": \" .. text\n    end\n\n    return \"Usage: change <from> to <to> | change <step|start-end> up|down [N]\"\nend)\n\naddCommand(\"swap\", \"swap <syllable> <syllable>\", \"Swap two syllables throughout the current pattern\", function(args)\n    local leftRaw, rightRaw = trim(args):match(\"^(%S+)%s+(%S+)$\")\n    local left = leftRaw and canonicalSolfegeSyllable(leftRaw)\n    local right = rightRaw and canonicalSolfegeSyllable(rightRaw)\n    if not left or not right or left == right then return \"Usage: swap <syllable> <syllable>\" end\n    local current, err = getCurrentSolfegeTokens()\n    if not current then return err end\n    local changed = 0\n    for i, token in ipairs(current) do\n        local nextToken, leftChanged = replaceTokenSyllable(token, left, \"__swap__\")\n        nextToken, rightChanged = replaceTokenSyllable(nextToken, right, left)\n        nextToken = nextToken:gsub(\"__swap__\", right)\n        current[i] = nextToken\n        if leftChanged or rightChanged then changed = changed + 1 end\n    end\n    if changed == 0 then return \"No \" .. left .. \" or \" .. right .. \" syllables found\" end\n    local text = setCurrentSolfegeTokens(current)\n    return \"Swapped \" .. left .. \" and \" .. right .. \": \" .. text\nend)\n\naddCommand(\"save\", \"save melody <name> | save <do re mi...> as <name>\", \"Save a solfege melody as a reusable template\", function(args)\n    args = trim(args)\n    if args == \"\" or args:lower() == \"melody\" or args:lower() == \"current\" then\n        return saveSolfegeMelody(nil, nil)\n    end\n\n    local lower = args:lower()\n    local currentName = args:match(\"^[Mm]elody%s+(.+)$\")\n        or args:match(\"^[Cc]urrent%s+[Aa]s%s+(.+)$\")\n        or args:match(\"^[Cc]urrent%s+(.+)$\")\n    if currentName then\n        return saveSolfegeMelody(currentName, nil)\n    end\n\n    local phrase, name = args:match(\"^(.-)%s+[Aa][Ss]%s+(.+)$\")\n    if phrase and name then\n        local solfegeText, err = solfegeTextOrUsage(phrase, \"Usage: save <do re mi...> as <name>\")\n        if not solfegeText then return err end\n        return saveSolfegeMelody(name, solfegeText)\n    end\n\n    if parseSolfegeChatTokens(args) then\n        return saveSolfegeMelody(nil, table.concat(parseSolfegeChatTokens(args), \" \"))\n    end\n\n    if lower:match(\"^as%s+\") then\n        return saveSolfegeMelody(args:gsub(\"^[Aa][Ss]%s+\", \"\"), nil)\n    end\n    return \"Usage: save melody <name> | save <do re mi...> as <name>\"\nend)\n\naddCommand(\"pause\", \"pause\", \"Pause playback\", function()\n    local pausedSolfegeLoop = false\n    if _actions.pauseSolfegeLoop then\n        pausedSolfegeLoop = _actions.pauseSolfegeLoop() == true\n    end\n    if _state.isPlaying and _actions.pausePlayback then\n        _actions.pausePlayback()\n        if pausedSolfegeLoop then\n            return \"Playback and solfege loop paused\"\n        end\n        return \"Playback paused\"\n    end\n    if pausedSolfegeLoop then\n        return \"Solfege playback paused\"\n    end\n    return \"Playback paused\"\nend)\n\naddCommand(\"resume\", \"resume\", \"Resume paused playback\", function()\n    local resumedSolfegeLoop = _actions.resumeSolfegeLoop and _actions.resumeSolfegeLoop() == true\n    if _state.isPaused and _actions.startPlayback then\n        local ok, err = _actions.startPlayback()\n        if not ok then\n            return err or \"Nothing to resume\"\n        end\n        return resumedSolfegeLoop and \"Playback and solfege loop resumed\" or \"Playback resumed\"\n    end\n    if resumedSolfegeLoop then\n        return \"Solfege playback resumed\"\n    end\n    if _state.isPlaying then return \"Already playing\" end\n    return \"Nothing to resume\"\nend)\n\naddCommand(\"key\", \"key <C/C#/D/...>\", \"Set key root\", function(args)\n    local name = trim(args):lower()\n    local val = NOTE_NAMES[name]\n    if not val then return \"Unknown key. Use: C, C#, D, D#, E, F, F#, G, G#, A, A#, B (or flats: Db, Eb, etc.)\" end\n    _state.keyNote = val\n    if _actions.savePreferences then _actions.savePreferences() end\n    return \"Key set to \" .. NOTE_DISPLAY[val + 1]\nend)\n\naddCommand(\"scale\", \"scale <mode>\", \"Set scale mode\", function(args)\n    local name = trim(args):lower()\n    if #name == 0 then\n        return \"Scales: \" .. table.concat(_core.solfegeScaleModes, \", \")\n    end\n    for _, mode in ipairs(_core.solfegeScaleModes) do\n        if mode:lower() == name or mode:lower():find(name, 1, true) then\n            if _actions.setScale then\n                _actions.setScale(mode)\n            else\n                _state.solfegeScale = mode\n                _state.scaleMode = mode\n                _core.solfegeNotes = _core.getSolfegeNotes(mode)\n                if _actions.savePreferences then _actions.savePreferences() end\n            end\n            return \"Scale set to \" .. mode\n        end\n    end\n    return \"Unknown scale. Options: \" .. table.concat(_core.solfegeScaleModes, \", \")\nend)\n\naddCommand(\"octave\", \"octave <1-7|up|down> | octave <step|start-end> <1-7>\", \"Set default or step octave\", function(args)\n    args = trim(args)\n    local lower = args:lower()\n    local function setDefaultOctave(n)\n        n = math.max(1, math.min(7, math.floor(tonumber(n) or 4)))\n        _state.keyOctave = n\n        _state.currentOctave = n\n        if _actions.savePreferences then _actions.savePreferences() end\n        return \"Octave set to \" .. n\n    end\n\n    if lower == \"up\" or lower == \"higher\" then\n        return setDefaultOctave((_state.currentOctave or _state.keyOctave or 4) + 1)\n    elseif lower == \"down\" or lower == \"lower\" then\n        return setDefaultOctave((_state.currentOctave or _state.keyOctave or 4) - 1)\n    end\n\n    local rangeRaw, octaveRaw = args:match(\"^(%S+)%s+(%d+)$\")\n    if rangeRaw and octaveRaw then\n        local octave = tonumber(octaveRaw)\n        if not octave or octave < 1 or octave > 7 then\n            return \"Usage: octave <step|start-end> <1-7>\"\n        end\n        local current, err = getCurrentSolfegeTokens()\n        if not current then return err end\n        local startIdx, endIdx = parseIndexRange(rangeRaw, #current, false)\n        if not startIdx then return \"Usage: octave <step|start-end> <1-7>\" end\n        if startIdx < 1 or startIdx > #current or endIdx > #current then\n            return \"Step out of range. Current length: \" .. #current\n        end\n        for i = startIdx, endIdx do\n            current[i] = applyTokenOctave(current[i], octave)\n        end\n        local text = setCurrentSolfegeTokens(current)\n        return \"Octave \" .. startIdx .. (endIdx ~= startIdx and (\"-\" .. endIdx) or \"\") .. \" set to \" .. octave .. \": \" .. text\n    end\n\n    local n = tonumber(args)\n    if not n or n < 1 or n > 7 then\n        return \"Usage: octave <1-7|up|down> | octave <step|start-end> <1-7>\"\n    end\n    return setDefaultOctave(n)\nend)\n\naddCommand(\"pattern-octave\", \"pattern-octave <up|down|+N|-N|N>\", \"Shift active pattern octave\", function(args)\n    args = trim(args)\n    local lower = args:lower()\n    local delta\n    if lower == \"up\" or lower == \"higher\" then\n        delta = 1\n    elseif lower == \"down\" or lower == \"lower\" then\n        delta = -1\n    elseif lower:match(\"^[%+%-]?%d+$\") then\n        local target = tonumber(lower)\n        local current = 0\n        if _state.sequenceOctaveTranspose and _state.activeSequenceIndex then\n            current = _state.sequenceOctaveTranspose[_state.activeSequenceIndex] or 0\n        end\n        delta = target - current\n    end\n    if not delta then\n        return \"Usage: pattern-octave <up|down|+N|-N|N>\"\n    end\n    local newValue\n    if _actions.shiftPatternOctave then\n        newValue = _actions.shiftPatternOctave(delta)\n    else\n        local idx = _state.activeSequenceIndex or 1\n        _state.sequenceOctaveTranspose = _state.sequenceOctaveTranspose or {}\n        local current = _state.sequenceOctaveTranspose[idx] or 0\n        newValue = math.max(-3, math.min(3, current + delta))\n        _state.sequenceOctaveTranspose[idx] = newValue\n        if _actions.savePreferences then _actions.savePreferences() end\n    end\n    if newValue == nil and _state.sequenceOctaveTranspose then\n        newValue = _state.sequenceOctaveTranspose[_state.activeSequenceIndex or 1]\n    end\n    local sign = (newValue or 0) > 0 and \"+\" or \"\"\n    return \"Pattern octave: \" .. sign .. tostring(newValue or 0)\nend)\n\naddCommand(\"project\", \"project name <name>\", \"Rename the current project\", function(args)\n    args = trim(args)\n    local sub, rest = args:match(\"^(%S+)%s*(.*)$\")\n    if (sub or \"\"):lower() ~= \"name\" then\n        return \"Usage: project name <name>\"\n    end\n    rest = trim(rest)\n    if rest == \"\" then return \"Usage: project name <name>\" end\n    if not _actions.renameProject then return \"Project rename is not available\" end\n    local ok, result = _actions.renameProject(rest)\n    if ok then\n        return \"Project renamed: \" .. tostring(result)\n    end\n    return tostring(result or \"Could not rename project\")\nend)\n\naddCommand(\"rename\", \"rename project <name>\", \"Rename project or saved melody\", function(args)\n    args = trim(args)\n    local target, rest = args:match(\"^(%S+)%s+(.+)$\")\n    if (target or \"\"):lower() == \"project\" then\n        if not _actions.renameProject then return \"Project rename is not available\" end\n        local ok, result = _actions.renameProject(rest)\n        if ok then\n            return \"Project renamed: \" .. tostring(result)\n        end\n        return tostring(result or \"Could not rename project\")\n    end\n    return \"Usage: rename project <name>\"\nend)\n\naddCommand(\"templates\", \"templates [category] | templates find <name>\", \"List and search chat templates\", function(args)\n    args = trim(args)\n    local sub, rest = args:match(\"^(%S+)%s*(.*)$\")\n    local lowerSub = (sub or \"\"):lower()\n    if lowerSub == \"find\" or lowerSub == \"search\" then\n        rest = trim(rest)\n        if rest == \"\" then return \"Usage: templates find <name>\" end\n        local matches = collectChatTemplateMatches(rest)\n        local projectMatches = collectProjectTemplateMatches(rest)\n        local lines = {formatTemplateMatches(\"Chat Templates\", matches, 10)}\n        if #projectMatches > 0 then\n            lines[#lines + 1] = formatTemplateMatches(\"Project Templates\", projectMatches, 6)\n        end\n        return table.concat(lines, \"\\n\")\n    end\n\n    local userTemplates = _state.userSolfegeTemplates or {}\n    if args:lower() == \"my\" or args:lower() == \"mine\" or args:lower() == \"melodies\" then\n        if #userTemplates == 0 then return \"No saved melodies yet\" end\n        local lines = {\"My Templates:\"}\n        for i, t in ipairs(userTemplates) do\n            lines[#lines+1] = \"  \" .. i .. \". \" .. (t.name or \"?\") .. \" - \" .. (t.text or \"\")\n        end\n        return table.concat(lines, \"\\n\")\n    end\n\n    local chatCats = getChatSolfegeTemplateCategories()\n    for _, cat in ipairs(chatCats) do\n        if args ~= \"\" and (cat.name or \"\"):lower():find(args:lower(), 1, true) then\n            local lines = {cat.name .. \":\"}\n            for i, t in ipairs(cat.templates or {}) do\n                lines[#lines + 1] = \"  \" .. i .. \". \" .. (t.name or \"?\") .. \" - \" .. (t.text or \"\")\n            end\n            return table.concat(lines, \"\\n\")\n        end\n    end\n\n    if not _tl and #chatCats == 0 then return \"Template library not available\" end\n    local cats = _tl and _tl.categories or {}\n    if #args == 0 then\n        local lines = {\"Categories:\"}\n        for _, cat in ipairs(chatCats) do\n            lines[#lines+1] = \"  \" .. (cat.name or \"Templates\")\n        end\n        for i, name in ipairs(cats) do\n            lines[#lines+1] = \"  \" .. i .. \". \" .. name\n        end\n        if #userTemplates > 0 then\n            lines[#lines+1] = \"  My Templates\"\n        end\n        lines[#lines+1] = \"Use: templates <category>, templates find <name>, template <name>\"\n        return table.concat(lines, \"\\n\")\n    end\n    local matchCat\n    local argsLower = args:lower()\n    for _, name in ipairs(cats) do\n        if name:lower() == argsLower or name:lower():find(argsLower, 1, true) then\n            matchCat = name\n            break\n        end\n    end\n    if not matchCat then return \"No category matching '\" .. args .. \"'\" end\n    local templates = _tl.getTemplatesByCategory(matchCat)\n    if not templates or #templates == 0 then return \"No templates in \" .. matchCat end\n    local lines = {matchCat .. \":\"}\n    for i, t in ipairs(templates) do\n        lines[#lines+1] = \"  \" .. i .. \". \" .. (t.name or t.id or \"?\")\n    end\n    return table.concat(lines, \"\\n\")\nend)\n\naddCommand(\"template\", \"template [add|set|play|loop|preview|load] <name>\", \"Use templates from chat\", function(args)\n    args = trim(args)\n    if args == \"\" then\n        return \"Usage: template <name> | template set <name> | template play <name> | template load <project template>\"\n    end\n\n    local sub, rest = args:match(\"^(%S+)%s*(.*)$\")\n    local mode = (sub or \"\"):lower()\n    rest = trim(rest)\n\n    if mode == \"load\" or mode == \"project\" then\n        if rest == \"\" then return \"Usage: template load <project template>\" end\n        if not _tl then return \"Project template library not available\" end\n        local item = findBestProjectTemplate(rest)\n        if not item or not item.template then return \"No project template matching '\" .. rest .. \"'\" end\n        local success = _tl.loadTemplate(_state, item.template, _core, {stepsOnly = _state.templateStepsOnly})\n        if success then\n            if _actions.onTemplateLoaded then _actions.onTemplateLoaded() end\n            return \"Loaded project template: \" .. item.name\n        end\n        return \"Failed to load project template\"\n    end\n\n    if mode ~= \"add\" and mode ~= \"set\" and mode ~= \"replace\" and mode ~= \"play\" and mode ~= \"hear\"\n       and mode ~= \"loop\" and mode ~= \"preview\" and mode ~= \"show\" then\n        rest = args\n        mode = \"add\"\n    end\n    if rest == \"\" then return \"Usage: template \" .. mode .. \" <name>\" end\n\n    local item, matches = findBestChatTemplate(rest)\n    if not item then\n        return \"No chat template matching '\" .. rest .. \"'\"\n    end\n    if mode == \"preview\" or mode == \"show\" then\n        local lines = {item.name .. \" (\" .. item.category .. \"):\", \"  \" .. (item.text or \"\")}\n        if matches and #matches > 1 then\n            lines[#lines + 1] = \"Other matches: \" .. math.min(#matches - 1, 9)\n        end\n        return table.concat(lines, \"\\n\")\n    end\n    return applyChatTemplate(item, mode)\nend)\n\naddCommand(\"load\", \"load <template name>\", \"Fuzzy-match and load a template\", function(args)\n    args = trim(args)\n    if #args == 0 then return \"Usage: load <template name>\" end\n    if not _tl then return \"Template library not available\" end\n    local query = args:lower()\n    for _, t in ipairs(_state.userSolfegeTemplates or {}) do\n        local tName = (t.name or \"\"):lower()\n        if tName:find(query, 1, true) then\n            if _actions.setSolfegeText then\n                _actions.setSolfegeText(t.text or \"\")\n            elseif _actions.addSolfegeText then\n                _actions.addSolfegeText(t.text or \"\")\n            end\n            return \"Loaded saved melody: \" .. (t.name or \"?\")\n        end\n    end\n    for _, catName in ipairs(_tl.categories or {}) do\n        local templates = _tl.getTemplatesByCategory(catName)\n        for _, t in ipairs(templates or {}) do\n            local tName = (t.name or t.id or \"\"):lower()\n            if tName:find(query, 1, true) then\n                local success = _tl.loadTemplate(_state, t, _core, {stepsOnly = _state.templateStepsOnly})\n                if success then\n                    if _actions.onTemplateLoaded then _actions.onTemplateLoaded() end\n                    return \"Loaded: \" .. (t.name or t.id)\n                end\n                return \"Failed to load template\"\n            end\n        end\n    end\n    return \"No template matching '\" .. args .. \"'\"\nend)\n\naddCommand(\"clear\", \"clear [all|text|lyrics|sequence]\", \"Clear sequence, text, lyrics, or all\", function(args)\n    args = trim(args):lower()\n    if args == \"\" or args == \"sequence\" or args == \"steps\" or args == \"melody\" then\n        if _actions.clearSequence then _actions.clearSequence() end\n        if _actions.clearSolfegeText then\n            _actions.clearSolfegeText()\n        elseif _actions.setSolfegeText then\n            _actions.setSolfegeText(\"\")\n        end\n        return \"Sequence cleared\"\n    elseif args == \"all\" or args == \"everything\" then\n        if _actions.clearAll then\n            _actions.clearAll()\n        else\n            if _actions.clearSequence then _actions.clearSequence() end\n            if _actions.clearLyrics then _actions.clearLyrics() end\n            if _actions.clearSolfegeText then\n                _actions.clearSolfegeText()\n            elseif _actions.setSolfegeText then\n                _actions.setSolfegeText(\"\")\n            end\n        end\n        return \"Cleared text, lyrics, and sequence\"\n    elseif args == \"text\" or args == \"textbox\" or args == \"text box\" or args == \"solfege\"\n        or args == \"solfege text\" or args == \"solfege text box\" then\n        if _actions.clearSolfegeText then\n            _actions.clearSolfegeText()\n        elseif _actions.setSolfegeText then\n            _actions.setSolfegeText(\"\")\n        end\n        return \"Solfege text cleared\"\n    elseif args == \"lyrics\" or args == \"words\" then\n        if _actions.clearLyrics then _actions.clearLyrics() end\n        return \"Lyrics cleared\"\n    end\n    return \"Usage: clear [all|text|lyrics|sequence]\"\nend)\n\naddCommand(\"mode\", \"mode <steps|lyrics|both>\", \"Set text input mode\", function(args)\n    local m = trim(args):lower()\n    if m == \"steps\" or m == \"lyrics\" or m == \"both\" then\n        _state.solfegeTextMode = m\n        if _actions.savePreferences then _actions.savePreferences() end\n        return \"Text mode set to \" .. m\n    end\n    return \"Usage: mode <steps|lyrics|both>\"\nend)\n\nlocal function loopCommand(args)\n    args = trim(args)\n    local lowerArgs = args:lower()\n    if lowerArgs == \"\" then\n        local source = _state.solfegeInputBuffer or _state._solfegeSeqText or \"\"\n        local tokens = collectSolfegeChatTokens(source)\n        if not tokens and _actions.getSolfegeText then\n            tokens = collectSolfegeChatTokens(_actions.getSolfegeText())\n        end\n        if tokens then\n            local solfegeText = table.concat(tokens, \" \")\n            if _actions.loopSolfegeText then\n                _actions.loopSolfegeText(solfegeText, true)\n            elseif _actions.playSolfegeText then\n                _actions.playSolfegeText(solfegeText, false, true)\n            end\n            return \"Looping: \" .. solfegeText\n        end\n        _state.loopPlayback = true\n        if _actions.onLoopPlaybackChanged then _actions.onLoopPlaybackChanged() end\n        if _actions.savePreferences then _actions.savePreferences() end\n        return \"Loop playback on\"\n    end\n\n    if lowerArgs == \"on\" then\n        _state.loopPlayback = true\n        if _actions.onLoopPlaybackChanged then _actions.onLoopPlaybackChanged() end\n        if _actions.savePreferences then _actions.savePreferences() end\n        return \"Loop playback on\"\n    end\n\n    if lowerArgs == \"off\" then\n        _state.loopPlayback = false\n        if _actions.onLoopPlaybackChanged then _actions.onLoopPlaybackChanged() end\n        if _actions.stopSolfegeLoop then\n            _actions.stopSolfegeLoop()\n        end\n        if _actions.savePreferences then _actions.savePreferences() end\n        return \"Loop playback off\"\n    end\n\n    if lowerArgs == \"clear\" then\n        if _actions.stopSolfegeLoop then\n            _actions.stopSolfegeLoop()\n        end\n        _state.stepLoopStart = nil\n        _state.stepLoopEnd = nil\n        return \"Loop cleared\"\n    end\n\n    local tokens = parseSolfegeChatTokens(args)\n    if tokens then\n        local solfegeText = table.concat(tokens, \" \")\n        if _actions.loopSolfegeText then\n            _actions.loopSolfegeText(solfegeText, true)\n        elseif _actions.playSolfegeText then\n            _actions.playSolfegeText(solfegeText, false, true)\n        end\n        return \"Looping: \" .. solfegeText\n    end\n\n    local s, e = args:match(\"(%d+)%s+(%d+)\")\n    if not s then return \"Usage: loop on | loop off | loop do re mi | loop <start> <end>\" end\n    s, e = tonumber(s), tonumber(e)\n    if s < 1 or e < s then return \"Invalid range. Start must be >= 1 and end >= start.\" end\n    _state.stepLoopStart = s\n    _state.stepLoopEnd = e\n    return \"Loop set: steps \" .. s .. \" to \" .. e\nend\n\naddCommand(\"loop\", \"loop on | loop off | loop do re mi | loop <start> <end>\", \"Set loop playback or loop solfege syllables\", loopCommand)\n\naddCommand(\"shortcuts\", \"shortcuts\", \"Show keyboard shortcuts\", function()\n    local lines = {\n        \"Keyboard Shortcuts:\",\n        \"  Cmd+Enter  Play / Pause\",\n        \"  Arrows     Navigate steps\",\n        \"  Cmd+Z      Undo\",\n        \"  Cmd+Sh+Z   Redo\",\n        \"  Cmd+C/X/V  Copy / Cut / Paste\",\n        \"  [ / ]      Loop start / end\",\n        \"  Del+click  Delete step\",\n        \"  Mute+click Toggle mute\",\n        \"  Chat Up/Down Recall commands\",\n        \"  Chat Cmd+Up/Down Scroll chat\",\n        \"  F11        Fullscreen\",\n        \"  Esc        Cancel / deselect\",\n        \"  `          Toggle command chat\",\n        \"  ?          Show help overlay\",\n    }\n    return table.concat(lines, \"\\n\")\nend)\n\naddCommand(\"ear\", \"ear <interval|chord|scale|dictation> [easy|medium|hard]\", \"Start ear training exercises\", function(args)\n    if not _actions.startEarTraining then\n        return \"Ear training is not available\"\n    end\n    local parts = {}\n    for w in trim(args):lower():gmatch(\"%S+\") do\n        parts[#parts + 1] = w\n    end\n    if #parts == 0 then\n        return \"Usage: ear <interval|chord|scale|dictation> [easy|medium|hard]\\n\" ..\n               \"  interval  - Identify intervals between two notes\\n\" ..\n               \"  chord     - Identify chord quality (major/minor/dim/aug)\\n\" ..\n               \"  scale     - Identify scale type\\n\" ..\n               \"  dictation - Write a melody in solfege\"\n    end\n    local exerciseType = parts[1]\n    local validTypes = {interval=true, chord=true, scale=true, dictation=true,\n                        intervals=true, chords=true, scales=true}\n    if exerciseType == \"intervals\" then exerciseType = \"interval\"\n    elseif exerciseType == \"chords\" then exerciseType = \"chord\"\n    elseif exerciseType == \"scales\" then exerciseType = \"scale\"\n    end\n    if not validTypes[exerciseType] then\n        return \"Unknown exercise: \" .. parts[1] .. \". Try: interval, chord, scale, or dictation\"\n    end\n    local difficulty = nil\n    if parts[2] then\n        local diffMap = {easy=1, medium=2, hard=3, [\"1\"]=1, [\"2\"]=2, [\"3\"]=3}\n        difficulty = diffMap[parts[2]]\n    end\n    _actions.startEarTraining(exerciseType, difficulty)\n    return \"Starting \" .. exerciseType .. \" ear training\"\nend)\n\naddAlias(\"eartraining\", \"ear\")\naddAlias(\"ear-training\", \"ear\")\naddAlias(\"listen\", \"ear\")\n\n-- Settings commands (replacing the Preferences screen)\n\nlocal function parseOnOff(text)\n    text = trim(text):lower()\n    if text == \"\" or text == \"toggle\" then return nil end\n    if isOneOf(text, {\"on\", \"true\", \"yes\", \"1\", \"enable\", \"enabled\"}) then return true end\n    if isOneOf(text, {\"off\", \"false\", \"no\", \"0\", \"disable\", \"disabled\"}) then return false end\n    return nil, \"invalid\"\nend\n\naddCommand(\"preview\", \"preview [sound|step] [on|off]\", \"Toggle sound or step preview\", function(args)\n    local parts = {}\n    for w in trim(args):lower():gmatch(\"%S+\") do parts[#parts + 1] = w end\n    if #parts == 0 then\n        return \"Sound preview: \" .. onOffLabel(_state.soundPreviewEnabled) ..\n               \"\\nStep preview: \" .. onOffLabel(_state.soundPreviewOnNavigation)\n    end\n    local target = parts[1]\n    local toggleArg = parts[2] or \"\"\n    if target == \"sound\" then\n        local desired, err = parseOnOff(toggleArg)\n        if err then return \"Usage: preview sound [on|off]\" end\n        if desired == nil then desired = not _state.soundPreviewEnabled end\n        if _actions.setSoundPreview then _actions.setSoundPreview(desired) end\n        return \"Sound preview: \" .. onOffLabel(desired)\n    elseif target == \"step\" or target == \"nav\" or target == \"navigation\" then\n        local desired, err = parseOnOff(toggleArg)\n        if err then return \"Usage: preview step [on|off]\" end\n        if desired == nil then desired = not _state.soundPreviewOnNavigation end\n        if _actions.setStepPreview then _actions.setStepPreview(desired) end\n        return \"Step preview: \" .. onOffLabel(desired)\n    elseif isOneOf(target, {\"on\", \"off\", \"true\", \"false\", \"yes\", \"no\"}) then\n        local desired = parseOnOff(target)\n        if _actions.setSoundPreview then _actions.setSoundPreview(desired) end\n        if _actions.setStepPreview then _actions.setStepPreview(desired) end\n        return \"Sound preview: \" .. onOffLabel(desired) .. \"\\nStep preview: \" .. onOffLabel(desired)\n    end\n    return \"Usage: preview [sound|step] [on|off]\"\nend)\n\naddCommand(\"sound\", \"sound [sample|synth|acappella|stream] [on|off]\", \"Set sound mode, a cappella, or stream audio\", function(args)\n    local parts = {}\n    for w in trim(args):lower():gmatch(\"%S+\") do parts[#parts + 1] = w end\n    if #parts == 0 then\n        local modeLabel = \"SYNTH\"\n        if _state.useSampleMode ~= nil then\n            modeLabel = _state.useSampleMode and \"SAMPLE\" or \"SYNTH\"\n        elseif _G.useSampleMode then\n            modeLabel = \"SAMPLE\"\n        end\n        return \"Sound mode: \" .. modeLabel ..\n               \"\\nA cappella: \" .. onOffLabel(_state.acapellaMode) ..\n               \"\\nStream audio: \" .. onOffLabel(_state.streamAudioEnabled)\n    end\n    local target = parts[1]\n    local toggleArg = parts[2] or \"\"\n    if target == \"sample\" then\n        if _actions.toggleSoundMode then _actions.toggleSoundMode() end\n        local mode = _G.useSampleMode and \"SAMPLE\" or \"SYNTH\"\n        return \"Sound mode: \" .. mode\n    elseif target == \"synth\" then\n        if _actions.toggleSoundMode then _actions.toggleSoundMode() end\n        local mode = _G.useSampleMode and \"SAMPLE\" or \"SYNTH\"\n        return \"Sound mode: \" .. mode\n    elseif target == \"acappella\" or target == \"a-cappella\" or target == \"cappella\" then\n        local desired, err = parseOnOff(toggleArg)\n        if err then return \"Usage: sound acappella [on|off]\" end\n        if desired == nil then desired = not _state.acapellaMode end\n        if _actions.setAcapellaMode then _actions.setAcapellaMode(desired) end\n        return \"A cappella: \" .. onOffLabel(desired)\n    elseif target == \"stream\" or target == \"streaming\" then\n        local desired, err = parseOnOff(toggleArg)\n        if err then return \"Usage: sound stream [on|off]\" end\n        if desired == nil then desired = not _state.streamAudioEnabled end\n        if _actions.setStreamAudioEnabled then _actions.setStreamAudioEnabled(desired) end\n        return \"Stream audio: \" .. onOffLabel(desired)\n    end\n    return \"Usage: sound [sample|synth|acappella|stream] [on|off]\"\nend)\n\naddCommand(\"training\", \"training [hide-labels|reveal|hide-sing|hands|random-root|random-octave] [on|off]\", \"Configure ear training display options\", function(args)\n    local parts = {}\n    for w in trim(args):lower():gmatch(\"%S+\") do parts[#parts + 1] = w end\n    if #parts == 0 then\n        return \"Hide note labels: \" .. onOffLabel(_state.hideNoteNamesDuringPlayback) ..\n               \"\\nReveal heard notes: \" .. onOffLabel(_state.earTrainingRevealAfterPlayback ~= false) ..\n               \"\\nHide notes (sing): \" .. onOffLabel(_state.hideNoteNamesDuringSing) ..\n               \"\\nShow hands: \" .. onOffLabel(_state.showHandsDuringPlayback) ..\n               \"\\nRandomize root: \" .. onOffLabel(_state.randomizeRootPlayback) ..\n               \"\\nRandomize octave: \" .. onOffLabel(_state.randomizeOctavePlayback)\n    end\n    local target = parts[1]\n    local toggleArg = parts[2] or \"\"\n\n    local function applyToggle(current, setter, label)\n        local desired, err = parseOnOff(toggleArg)\n        if err then return \"Usage: training \" .. target .. \" [on|off]\" end\n        if desired == nil then desired = not current end\n        if setter then setter(desired) end\n        return label .. \": \" .. onOffLabel(desired)\n    end\n\n    if target == \"hide-labels\" or target == \"labels\" or target == \"hide\" then\n        return applyToggle(_state.hideNoteNamesDuringPlayback, _actions.setHideNoteLabels, \"Hide note labels\")\n    elseif target == \"reveal\" or target == \"reveal-notes\" then\n        return applyToggle(_state.earTrainingRevealAfterPlayback ~= false, _actions.setRevealHeardNotes, \"Reveal heard notes\")\n    elseif target == \"hide-sing\" or target == \"sing\" then\n        return applyToggle(_state.hideNoteNamesDuringSing, _actions.setHideNotesSing, \"Hide notes (sing)\")\n    elseif target == \"hands\" or target == \"show-hands\" then\n        return applyToggle(_state.showHandsDuringPlayback, _actions.setShowHands, \"Show hands\")\n    elseif target == \"random-root\" or target == \"root\" then\n        return applyToggle(_state.randomizeRootPlayback, _actions.setRandomizeRoot, \"Randomize root\")\n    elseif target == \"random-octave\" or target == \"random-octaves\" then\n        return applyToggle(_state.randomizeOctavePlayback, _actions.setRandomizeOctave, \"Randomize octave\")\n    end\n    return \"Usage: training [hide-labels|reveal|hide-sing|hands|random-root|random-octave] [on|off]\"\nend)\n\naddCommand(\"tonic\", \"tonic [on|off|beats N|note C|octave N]\", \"Configure tonic playback settings\", function(args)\n    local parts = {}\n    for w in trim(args):lower():gmatch(\"%S+\") do parts[#parts + 1] = w end\n    if #parts == 0 then\n        local keyName = NOTE_DISPLAY[(_state.keyNote or 0) + 1] or \"C\"\n        return \"Play tonic: \" .. onOffLabel(_state.playKeyBeforeSteps) ..\n               \"\\nLead-in beats: \" .. (_state.keyLeadInBeats or 1) ..\n               \"\\nKey note: \" .. keyName ..\n               \"\\nKey octave: \" .. (_state.keyOctave or 4)\n    end\n    local target = parts[1]\n    if isOneOf(target, {\"on\", \"off\", \"yes\", \"no\", \"true\", \"false\"}) then\n        local desired = parseOnOff(target)\n        if _actions.setPlayKeyBeforeSteps then _actions.setPlayKeyBeforeSteps(desired) end\n        return \"Play tonic: \" .. onOffLabel(desired)\n    elseif target == \"beats\" or target == \"lead-in\" or target == \"leadin\" then\n        local n = tonumber(parts[2])\n        if not n or n < 1 or n > 4 then return \"Usage: tonic beats <1-4>\" end\n        if _actions.setKeyLeadIn then _actions.setKeyLeadIn(n) end\n        return \"Tonic lead-in: \" .. n .. \" beat\" .. (n > 1 and \"s\" or \"\")\n    elseif target == \"note\" then\n        local noteName = (parts[2] or \"\"):upper()\n        local noteIndex = NOTE_NAMES[noteName:lower()]\n        if noteIndex == nil then return \"Usage: tonic note <C/C#/D/D#/E/F/F#/G/G#/A/A#/B>\" end\n        if _actions.setKeyNote then _actions.setKeyNote(noteIndex) end\n        return \"Key note: \" .. NOTE_DISPLAY[noteIndex + 1]\n    elseif target == \"octave\" or target == \"oct\" then\n        local n = tonumber(parts[2])\n        if not n or n < 2 or n > 7 then return \"Usage: tonic octave <2-7>\" end\n        if _actions.setKeyOctave then _actions.setKeyOctave(n) end\n        return \"Key octave: \" .. n\n    elseif NOTE_NAMES[target] ~= nil then\n        local noteIndex = NOTE_NAMES[target]\n        if _actions.setKeyNote then _actions.setKeyNote(noteIndex) end\n        return \"Key note: \" .. NOTE_DISPLAY[noteIndex + 1]\n    end\n    return \"Usage: tonic [on|off|beats N|note C|octave N]\"\nend)\n\nlocal PLAYBACK_STOP_MAP = {\n    [\"off\"] = 0, [\"0\"] = 0, [\"none\"] = 0, [\"never\"] = 0,\n    [\"30s\"] = 30, [\"30\"] = 30,\n    [\"1m\"] = 60, [\"60s\"] = 60, [\"60\"] = 60,\n    [\"2m\"] = 120, [\"120s\"] = 120, [\"120\"] = 120,\n    [\"5m\"] = 300, [\"300s\"] = 300, [\"300\"] = 300,\n    [\"10m\"] = 600, [\"600s\"] = 600, [\"600\"] = 600,\n    [\"15m\"] = 900, [\"900s\"] = 900, [\"900\"] = 900,\n    [\"20m\"] = 1200, [\"1200s\"] = 1200, [\"1200\"] = 1200,\n    [\"30m\"] = 1800, [\"1800s\"] = 1800, [\"1800\"] = 1800,\n}\n\naddCommand(\"playback-stop\", \"playback-stop [off|30s|1m|2m|5m|10m|15m|20m|30m]\", \"Set auto-stop timer for playback\", function(args)\n    local text = trim(args):lower()\n    if text == \"\" then\n        local label = _core and _core.getPlaybackStopLabel and _core.getPlaybackStopLabel(_state.playbackStopSeconds) or tostring(_state.playbackStopSeconds or 0)\n        return \"Playback auto-stop: \" .. label\n    end\n    local seconds = PLAYBACK_STOP_MAP[text]\n    if seconds == nil then\n        return \"Usage: playback-stop [off|30s|1m|2m|5m|10m|15m|20m|30m]\"\n    end\n    if _actions.setPlaybackStop then _actions.setPlaybackStop(seconds) end\n    local label = _core and _core.getPlaybackStopLabel and _core.getPlaybackStopLabel(seconds) or tostring(seconds)\n    return \"Playback auto-stop: \" .. label\nend)\n\naddCommand(\"default-mode\", \"default-mode [sing|compose]\", \"Set default startup mode\", function(args)\n    local text = trim(args):lower()\n    if text == \"\" then\n        return \"Default mode: \" .. (_state.defaultSingSolfegeMode and \"SING\" or \"COMPOSE\")\n    end\n    if text == \"sing\" or text == \"solfege\" then\n        if _actions.setDefaultMode then _actions.setDefaultMode(true) end\n        return \"Default mode: SING\"\n    elseif text == \"compose\" or text == \"sequencer\" then\n        if _actions.setDefaultMode then _actions.setDefaultMode(false) end\n        return \"Default mode: COMPOSE\"\n    end\n    return \"Usage: default-mode [sing|compose]\"\nend)\n\naddCommand(\"settings\", \"settings\", \"Show all settings\", function()\n    local s = _state\n    local tonicNote = NOTE_DISPLAY[(s.keyNote or 0) + 1] or \"C\"\n    local rootNote = NOTE_DISPLAY[(s.rootNote or 0) + 1] or \"C\"\n    local droneNote = getSolfegeDisplayName(s.droneNoteSelection or 0)\n    local stopLabel = _core and _core.getPlaybackStopLabel and _core.getPlaybackStopLabel(s.playbackStopSeconds or 0) or \"OFF\"\n    local defaultLength\n    if _core and _core.getStepBeatsShortLabel then\n        defaultLength = _core.getStepBeatsShortLabel(s.stepBeats or 1)\n    else\n        defaultLength = tostring(s.stepBeats or 1)\n    end\n    local lines = {\n        \"SOUND\",\n        \"  Sound preview: \" .. onOffLabel(s.soundPreviewEnabled),\n        \"  Step preview: \" .. onOffLabel(s.soundPreviewOnNavigation),\n        \"  A cappella: \" .. onOffLabel(s.acapellaMode),\n        \"  Stream audio: \" .. onOffLabel(s.streamAudioEnabled),\n        \"\",\n        \"PLAYBACK\",\n        \"  Tempo: \" .. (s.tempo or s.defaultTempo or 120) .. \" BPM\",\n        \"  Step duration: \" .. defaultLength,\n        \"  Auto-stop: \" .. stopLabel,\n        \"  Default mode: \" .. (s.defaultSingSolfegeMode and \"SING\" or \"COMPOSE\"),\n        \"\",\n        \"KEY & SCALE\",\n        \"  Key (Do): \" .. rootNote,\n        \"  Scale: \" .. (s.solfegeScale or \"major\"),\n        \"\",\n        \"TONIC\",\n        \"  Play tonic: \" .. onOffLabel(s.playKeyBeforeSteps),\n        \"  Lead-in: \" .. (s.keyLeadInBeats or 1) .. \" beat\" .. ((s.keyLeadInBeats or 1) > 1 and \"s\" or \"\"),\n        \"  Key note: \" .. tonicNote,\n        \"  Key octave: \" .. (s.keyOctave or 4),\n        \"\",\n        \"DRONE\",\n        \"  Drone: \" .. onOffLabel(s.droneEnabled),\n        \"  Drone note: \" .. droneNote,\n        \"  Drone octave: \" .. (s.droneOctave or 4),\n        \"\",\n        \"EAR TRAINING\",\n        \"  Hide note labels: \" .. onOffLabel(s.hideNoteNamesDuringPlayback),\n        \"  Reveal heard notes: \" .. onOffLabel(s.earTrainingRevealAfterPlayback ~= false),\n        \"  Hide notes (sing): \" .. onOffLabel(s.hideNoteNamesDuringSing),\n        \"  Show hands: \" .. onOffLabel(s.showHandsDuringPlayback),\n        \"  Randomize root: \" .. onOffLabel(s.randomizeRootPlayback),\n        \"  Randomize octave: \" .. onOffLabel(s.randomizeOctavePlayback),\n    }\n    return table.concat(lines, \"\\n\")\nend)\n\naddAlias(\"setting\", \"settings\")\n\naddCommand(\"reset\", \"reset preferences\", \"Reset all preferences to defaults\", function(args)\n    local text = trim(args):lower()\n    if text ~= \"preferences\" and text ~= \"prefs\" and text ~= \"all\" and text ~= \"\" then\n        return \"Usage: reset preferences\"\n    end\n    if _actions.resetPreferences then _actions.resetPreferences() end\n    return \"All preferences reset to defaults\"\nend)\n\naddCommand(\"pattern\", \"pattern [category|list|load N|octave N]\", \"Browse and load sequencer patterns\", function(args)\n    local parts = {}\n    for w in trim(args):gmatch(\"%S+\") do parts[#parts + 1] = w end\n    if #parts == 0 or (parts[1] and parts[1]:lower() == \"list\") then\n        if not _actions.getPatternTypes then return \"Pattern library not available\" end\n        local types = _actions.getPatternTypes()\n        local lines = {\"Pattern categories:\"}\n        for i, name in ipairs(types) do\n            lines[#lines + 1] = \"  \" .. i .. \". \" .. name\n        end\n        lines[#lines + 1] = \"Use: pattern <category name> to see patterns\"\n        return table.concat(lines, \"\\n\")\n    end\n    local lowerFirst = parts[1]:lower()\n    if lowerFirst == \"octave\" or lowerFirst == \"oct\" then\n        local n = tonumber(parts[2])\n        if not n or n < 2 or n > 7 then return \"Usage: pattern octave <2-7>\" end\n        _state.patternOctave = n\n        if _actions.savePreferences then _actions.savePreferences() end\n        return \"Pattern octave: \" .. n\n    end\n    if lowerFirst == \"load\" then\n        local idx = tonumber(parts[2])\n        if not idx then return \"Usage: pattern load <number>\" end\n        if not _actions.loadPattern then return \"Pattern loading not available\" end\n        _actions.loadPattern(idx)\n        return \"Loaded pattern \" .. idx\n    end\n    if not _actions.getPatternTypes then return \"Pattern library not available\" end\n    local types = _actions.getPatternTypes()\n    local query = trim(args):lower()\n    local matchIdx\n    for i, name in ipairs(types) do\n        if name:lower() == query or name:lower():find(query, 1, true) then\n            matchIdx = i\n            break\n        end\n    end\n    if not matchIdx then\n        local numIdx = tonumber(query)\n        if numIdx and numIdx >= 1 and numIdx <= #types then\n            matchIdx = numIdx\n        end\n    end\n    if not matchIdx then return \"No category matching '\" .. trim(args) .. \"'. Type 'pattern' to see categories.\" end\n    if not _actions.getPatternList then return \"Pattern library not available\" end\n    local patternList = _actions.getPatternList(matchIdx)\n    if not patternList or #patternList == 0 then return \"No patterns in \" .. types[matchIdx] end\n    local lines = {types[matchIdx] .. \" (octave \" .. (_state.patternOctave or 4) .. \"):\"}\n    for i, p in ipairs(patternList) do\n        lines[#lines + 1] = \"  \" .. i .. \". \" .. (p.name or \"?\")\n    end\n    lines[#lines + 1] = \"Use: pattern load <number> to load\"\n    _state.selectedPatternType = matchIdx\n    return table.concat(lines, \"\\n\")\nend)\n\nlocal NATURAL_COUNT_WORDS = {\n    one = 1, once = 1,\n    two = 2, twice = 2,\n    three = 3,\n    four = 4,\n    five = 5,\n    six = 6,\n    seven = 7,\n    eight = 8,\n    nine = 9,\n    ten = 10,\n    eleven = 11,\n    twelve = 12,\n    sixteen = 16,\n}\n\nlocal NATURAL_NUMBER_WORDS = {\n    zero = 0,\n    one = 1,\n    two = 2,\n    three = 3,\n    four = 4,\n    five = 5,\n    six = 6,\n    seven = 7,\n    eight = 8,\n    nine = 9,\n    ten = 10,\n    eleven = 11,\n    twelve = 12,\n    thirteen = 13,\n    fourteen = 14,\n    fifteen = 15,\n    sixteen = 16,\n    seventeen = 17,\n    eighteen = 18,\n    nineteen = 19,\n    twenty = 20,\n    thirty = 30,\n    forty = 40,\n    fifty = 50,\n    sixty = 60,\n    seventy = 70,\n    eighty = 80,\n    ninety = 90,\n    hundred = 100,\n}\n\nlocal NATURAL_DURATION_WORDS = {\n    whole = \"1\",\n    halves = \"2\", half = \"2\",\n    quarters = \"4\", quarter = \"4\",\n    eighths = \"8\", eighth = \"8\",\n    sixteenths = \"16\", sixteenth = \"16\",\n    thirtyseconds = \"32\", thirtysecond = \"32\", [\"thirty-second\"] = \"32\",\n}\n\nlocal function naturalCount(raw)\n    local text = trim(raw):lower()\n    return tonumber(text) or NATURAL_COUNT_WORDS[text]\nend\n\nlocal function naturalNumber(raw)\n    local text = trim(raw):lower():gsub(\"%s+\", \" \"):gsub(\"%-\", \" \")\n    local direct = tonumber(text)\n    if direct then return direct end\n    if NATURAL_NUMBER_WORDS[text] then return NATURAL_NUMBER_WORDS[text] end\n\n    local total, current, sawWord = 0, 0, false\n    for word in text:gmatch(\"%S+\") do\n        local value = NATURAL_NUMBER_WORDS[word]\n        if not value then return nil end\n        sawWord = true\n        if word == \"hundred\" then\n            if current == 0 then current = 1 end\n            current = current * 100\n        else\n            current = current + value\n        end\n    end\n    if not sawWord then return nil end\n    return total + current\nend\n\nlocal function naturalDuration(raw)\n    local text = trim(raw):lower():gsub(\"%s+notes$\", \"\"):gsub(\"%s+note$\", \"\"):gsub(\"%s+\", \"-\")\n    return NATURAL_DURATION_WORDS[text] or text:match(\"^1/(%d+)$\") or text:match(\"^(%d+)$\")\nend\n\nlocal function naturalRange(raw)\n    local text = trim(raw):lower()\n    text = text:gsub(\"^notes?%s+\", \"\")\n    text = text:gsub(\"^steps?%s+\", \"\")\n    text = text:gsub(\"%s+through%s+\", \"-\")\n    text = text:gsub(\"%s+thru%s+\", \"-\")\n    text = text:gsub(\"%s+to%s+\", \"-\")\n    text = text:gsub(\"%s+\", \"\")\n    if text:match(\"^%d+$\") or text:match(\"^%d+%-%d+$\") then\n        return text\n    end\n    return nil\nend\n\nlocal function parseNaturalIntent(inputText)\n    local text = normalizeSpaces(inputText)\n    if text == \"\" then return nil end\n    text = text:gsub(\"%s*[%?%.!]$\", \"\")\n    local lower = text:lower():gsub(\"octive\", \"octave\"):gsub(\"%f[%w]octv%f[%W]\", \"octave\")\n    local stripped = lower\n\n    local softened = text\n    local changed = false\n    local prefixPatterns = {\n        \"^[Pp]lease%s+\",\n        \"^[Cc]an%s+you%s+\",\n        \"^[Cc]ould%s+you%s+\",\n        \"^[Ww]ould%s+you%s+\",\n        \"^[Ww]ill%s+you%s+\",\n        \"^[Ii]%s+want%s+you%s+to%s+\",\n        \"^[Ii]%s+want%s+to%s+\",\n        \"^[Ll]et'?s%s+\",\n    }\n    for _, pattern in ipairs(prefixPatterns) do\n        local nextText = softened:gsub(pattern, \"\", 1)\n        if nextText ~= softened then\n            softened = nextText\n            changed = true\n            break\n        end\n    end\n    local withoutTrailingPlease = softened:gsub(\"%s+[Pp]lease[%?%.!]?$\", \"\")\n    if withoutTrailingPlease ~= softened then\n        softened = withoutTrailingPlease\n        changed = true\n    end\n    local withoutTrailingFiller = softened:gsub(\"%s+[Ff]or%s+me[%?%.!]?$\", \"\")\n    if withoutTrailingFiller ~= softened then\n        softened = withoutTrailingFiller\n        changed = true\n    end\n    if changed then\n        return parseNaturalIntent(softened)\n    end\n\n    if isOneOf(stripped, {\n        \"help\", \"show help\", \"what can i type\", \"what can i do\",\n        \"commands\", \"show commands\", \"show examples\", \"examples\",\n        \"how do i use this\", \"how do i use chat\", \"what should i type\"\n    }) then\n        return \"help\"\n    end\n\n    if isOneOf(stripped, {\n        \"info\", \"status\",\n        \"what are the current settings\",\n        \"what is the current state\", \"what key am i in\", \"what scale am i in\",\n        \"current scale\", \"current key\", \"current tempo\", \"current octave\",\n        \"current loop\", \"loop status\", \"current volume\", \"volume status\",\n        \"what is the volume\", \"what's the volume\"\n    }) then\n        if stripped:find(\"volume\", 1, true) then\n            return \"volume\"\n        end\n        return \"info\"\n    end\n\n    local volumeRaw = stripped:match(\"^set%s+volume%s+to%s+([%d%.]+%%?)$\")\n        or stripped:match(\"^set%s+the%s+volume%s+to%s+([%d%.]+%%?)$\")\n        or stripped:match(\"^change%s+volume%s+to%s+([%d%.]+%%?)$\")\n        or stripped:match(\"^change%s+the%s+volume%s+to%s+([%d%.]+%%?)$\")\n        or stripped:match(\"^turn%s+volume%s+to%s+([%d%.]+%%?)$\")\n        or stripped:match(\"^turn%s+the%s+volume%s+to%s+([%d%.]+%%?)$\")\n        or stripped:match(\"^volume%s+([%d%.]+%%?)$\")\n    if volumeRaw then\n        return \"volume \" .. volumeRaw\n    end\n    local function matchVolumeDirection(pattern)\n        local dir, amount = stripped:match(pattern)\n        if dir then return dir, amount end\n        return nil, nil\n    end\n    local volumeDirection, volumeAmount = matchVolumeDirection(\"^turn%s+volume%s+(up)%s*([%d%.]*%%?)$\")\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^turn%s+the%s+volume%s+(up)%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^turn%s+volume%s+(down)%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^turn%s+the%s+volume%s+(down)%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^(increase)%s+volume%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^(increase)%s+the%s+volume%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^(decrease)%s+volume%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^(decrease)%s+the%s+volume%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^make%s+volume%s+(louder)%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^make%s+the%s+volume%s+(louder)%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^make%s+volume%s+(quieter)%s*([%d%.]*%%?)$\") end\n    if not volumeDirection then volumeDirection, volumeAmount = matchVolumeDirection(\"^make%s+the%s+volume%s+(quieter)%s*([%d%.]*%%?)$\") end\n    if volumeDirection then\n        if volumeDirection == \"louder\" then volumeDirection = \"up\" end\n        if volumeDirection == \"quieter\" then volumeDirection = \"down\" end\n        return \"volume \" .. volumeDirection .. \" \" .. trim(volumeAmount or \"\")\n    end\n\n    if stripped:match(\"^ask%s+(.+)$\") then\n        return \"ask \" .. trim(text:gsub(\"^[Aa]sk%s+\", \"\", 1))\n    end\n    if stripped:find(\"pattern\", 1, true) or stripped:find(\"melody\", 1, true)\n       or stripped:find(\"note\", 1, true) or stripped:find(\"notes\", 1, true) or stripped:find(\"solfege\", 1, true)\n       or stripped:find(\"leaps\", 1, true) or stripped:find(\"stepwise\", 1, true)\n       or stripped:find(\"chords\", 1, true) or stripped:find(\"rests\", 1, true)\n       or stripped:find(\"rhythm\", 1, true) or stripped:find(\"duration\", 1, true)\n       or stripped:find(\"octave\", 1, true) or stripped:find(\"lyrics\", 1, true) then\n        local questionLike = stripped:match(\"^what%s+\")\n            or stripped:match(\"^what's%s+\")\n            or stripped:match(\"^how%s+\")\n            or stripped:match(\"^does%s+\")\n            or stripped:match(\"^do%s+\")\n            or stripped:match(\"^is%s+\")\n            or stripped:match(\"^are%s+\")\n            or stripped:match(\"^describe%s+\")\n            or stripped:match(\"^tell%s+me%s+\")\n        if questionLike then\n            return \"ask \" .. text\n        end\n    end\n\n    if isOneOf(stripped, {\"add drone\", \"start drone\", \"play drone\", \"turn on drone\", \"drone on\"}) then\n        return \"drone on\"\n    end\n    if isOneOf(stripped, {\"stop drone\", \"remove drone\", \"turn off drone\", \"drone off\"}) then\n        return \"drone off\"\n    end\n    local droneNote = stripped:match(\"^drone%s+(.+)$\")\n        or stripped:match(\"^set%s+drone%s+to%s+(.+)$\")\n    if droneNote then\n        return \"drone \" .. droneNote\n    end\n\n    if isOneOf(stripped, {\"stop\", \"stop playing\", \"stop playback\", \"stop the melody\", \"stop the song\"}) then\n        return \"stop\"\n    end\n    if isOneOf(stripped, {\"pause\", \"pause playback\", \"pause the melody\", \"pause the song\"}) then\n        return \"pause\"\n    end\n    if isOneOf(stripped, {\"resume\", \"resume playback\", \"continue\", \"continue playback\", \"keep going\"}) then\n        return \"resume\"\n    end\n    if isOneOf(stripped, {\"play\", \"start\", \"start playing\", \"start playback\", \"play the song\", \"start the song\"}) then\n        return \"play\"\n    end\n    if isOneOf(stripped, {\n        \"play this\", \"play current\", \"play the current melody\", \"play the melody\",\n        \"hear this\", \"hear current\", \"hear the current melody\", \"hear the melody\",\n        \"listen to this\", \"listen to current\", \"listen to the current melody\", \"listen to the melody\"\n    }) then\n        return \"play current\"\n    end\n    local scaleIntent = stripped:match(\"^play%s+the%s+(.+)%s+scale$\")\n        or stripped:match(\"^play%s+a%s+(.+)%s+scale$\")\n        or stripped:match(\"^play%s+(.+)%s+scale$\")\n        or stripped:match(\"^hear%s+the%s+(.+)%s+scale$\")\n        or stripped:match(\"^hear%s+a%s+(.+)%s+scale$\")\n        or stripped:match(\"^hear%s+(.+)%s+scale$\")\n        or stripped:match(\"^listen%s+to%s+the%s+(.+)%s+scale$\")\n        or stripped:match(\"^listen%s+to%s+a%s+(.+)%s+scale$\")\n        or stripped:match(\"^listen%s+to%s+(.+)%s+scale$\")\n    if not scaleIntent then\n        local candidate = stripped:match(\"^play%s+the%s+(.+)$\")\n            or stripped:match(\"^hear%s+the%s+(.+)$\")\n            or stripped:match(\"^listen%s+to%s+the%s+(.+)$\")\n        if candidate and buildScalePhrase(candidate) then\n            scaleIntent = candidate\n        end\n    end\n    if scaleIntent and buildScalePhrase(scaleIntent) then\n        return \"play \" .. scaleIntent\n    end\n\n    if isOneOf(stripped, {\"turn loop on\", \"turn on loop\", \"enable loop\", \"loop on\", \"repeat playback\"}) then\n        return \"loop on\"\n    end\n    if isOneOf(stripped, {\"loop this\", \"loop current\", \"loop the current melody\", \"loop the melody\", \"loop playback\"}) then\n        return \"loop\"\n    end\n    if isOneOf(stripped, {\"stop looping\", \"turn loop off\", \"turn off loop\", \"disable loop\", \"loop off\"}) then\n        return \"loop off\"\n    end\n    if isOneOf(stripped, {\"mute chat\", \"mute the chat\", \"mute auditions\", \"mute chat auditions\"}) then\n        return \"mute on\"\n    end\n    if isOneOf(stripped, {\"unmute chat\", \"unmute the chat\", \"unmute auditions\", \"unmute chat auditions\"}) then\n        return \"mute off\"\n    end\n    if isOneOf(stripped, {\n        \"clear all\", \"clear everything\",\n        \"clear solfege text box lyrics and steps sequence\",\n        \"clear lyrics and steps\", \"clear lyrics and sequence\", \"clear steps and lyrics\",\n        \"clear sequence and lyrics\", \"clear words and melody\", \"start over\"\n    }) then\n        return \"clear all\"\n    end\n    if isOneOf(stripped, {\"clear\", \"clear sequence\", \"clear the sequence\", \"clear melody\", \"clear the melody\", \"clear steps\", \"clear the steps\"}) then\n        return \"clear\"\n    end\n    if isOneOf(stripped, {\"clear text\", \"clear the text\", \"clear text box\", \"clear the text box\", \"clear solfege\", \"clear solfege text\"}) then\n        return \"clear text\"\n    end\n    if isOneOf(stripped, {\"clear lyrics\", \"clear the lyrics\", \"clear words\", \"clear the words\"}) then\n        return \"clear lyrics\"\n    end\n    if isOneOf(stripped, {\"make it faster\", \"make the melody faster\", \"go faster\", \"speed up\", \"speedup\"}) then\n        return \"faster\"\n    end\n    if isOneOf(stripped, {\"make it slower\", \"make the melody slower\", \"go slower\", \"slow down\", \"slowdown\"}) then\n        return \"slower\"\n    end\n\n    local fasterAmount = stripped:match(\"^make%s+it%s+faster%s+by%s+(%d+)$\")\n        or stripped:match(\"^make%s+the%s+melody%s+faster%s+by%s+(%d+)$\")\n        or stripped:match(\"^faster%s+by%s+(%d+)$\")\n        or stripped:match(\"^speed%s+up%s+by%s+(%d+)$\")\n        or stripped:match(\"^speedup%s+by%s+(%d+)$\")\n        or stripped:match(\"^go%s+faster%s+by%s+(%d+)$\")\n    if fasterAmount then\n        return \"faster \" .. fasterAmount\n    end\n\n    local slowerAmount = stripped:match(\"^make%s+it%s+slower%s+by%s+(%d+)$\")\n        or stripped:match(\"^make%s+the%s+melody%s+slower%s+by%s+(%d+)$\")\n        or stripped:match(\"^slower%s+by%s+(%d+)$\")\n        or stripped:match(\"^slow%s+down%s+by%s+(%d+)$\")\n        or stripped:match(\"^slowdown%s+by%s+(%d+)$\")\n        or stripped:match(\"^go%s+slower%s+by%s+(%d+)$\")\n    if slowerAmount then\n        return \"slower \" .. slowerAmount\n    end\n\n    local key = stripped:match(\"^set%s+key%s+to%s+([a-g][b#]?)$\")\n        or stripped:match(\"^set%s+the%s+key%s+to%s+([a-g][b#]?)$\")\n        or stripped:match(\"^change%s+key%s+to%s+([a-g][b#]?)$\")\n        or stripped:match(\"^change%s+the%s+key%s+to%s+([a-g][b#]?)$\")\n        or stripped:match(\"^switch%s+key%s+to%s+([a-g][b#]?)$\")\n        or stripped:match(\"^switch%s+the%s+key%s+to%s+([a-g][b#]?)$\")\n        or stripped:match(\"^key%s+of%s+([a-g][b#]?)$\")\n        or stripped:match(\"^put%s+it%s+in%s+([a-g][b#]?)$\")\n        or stripped:match(\"^make%s+it%s+in%s+([a-g][b#]?)$\")\n    if key then\n        return \"key \" .. key\n    end\n\n    local tempoRaw = stripped:match(\"^set%s+tempo%s+to%s+([%w%s%-]+)$\")\n        or stripped:match(\"^set%s+the%s+tempo%s+to%s+([%w%s%-]+)$\")\n        or stripped:match(\"^set%s+bpm%s+to%s+([%w%s%-]+)$\")\n        or stripped:match(\"^change%s+tempo%s+to%s+([%w%s%-]+)$\")\n        or stripped:match(\"^change%s+the%s+tempo%s+to%s+([%w%s%-]+)$\")\n        or stripped:match(\"^tempo%s+([%w%s%-]+)$\")\n        or stripped:match(\"^bpm%s+([%w%s%-]+)$\")\n        or stripped:match(\"^make%s+it%s+([%w%s%-]+)%s*bpm$\")\n        or stripped:match(\"^make%s+the%s+tempo%s+([%w%s%-]+)$\")\n    if tempoRaw then\n        local tempo = naturalNumber(tempoRaw)\n        if tempo then\n            return \"bpm \" .. tempo\n        end\n    end\n\n    local tempo = stripped:match(\"^set%s+tempo%s+to%s+(%d+)$\")\n    if tempo then\n        return \"bpm \" .. tempo\n    end\n\n    local scale = stripped:match(\"^set%s+scale%s+to%s+(.+)$\")\n        or stripped:match(\"^set%s+the%s+scale%s+to%s+(.+)$\")\n        or stripped:match(\"^change%s+scale%s+to%s+(.+)$\")\n        or stripped:match(\"^change%s+the%s+scale%s+to%s+(.+)$\")\n        or stripped:match(\"^switch%s+scale%s+to%s+(.+)$\")\n        or stripped:match(\"^switch%s+the%s+scale%s+to%s+(.+)$\")\n        or stripped:match(\"^switch%s+to%s+(.+)%s+scale$\")\n        or stripped:match(\"^switch%s+to%s+(.+)$\")\n        or stripped:match(\"^go%s+to%s+(.+)%s+scale$\")\n        or stripped:match(\"^in%s+(.+)$\")\n        or stripped:match(\"^use%s+(.+)%s+scale$\")\n        or stripped:match(\"^use%s+(.+)$\")\n        or stripped:match(\"^make%s+it%s+(.+)%s+scale$\")\n        or stripped:match(\"^make%s+it%s+(.+)$\")\n        or stripped:match(\"^(.+)%s+scale$\")\n    if scale then\n        local normalized = scale:gsub(\"%s+\", \"_\")\n        -- Verify it's actually a known scale before routing\n        if _core and _core.solfegeScaleModes then\n            for _, mode in ipairs(_core.solfegeScaleModes) do\n                if mode:lower() == normalized or mode:lower():find(normalized, 1, true) then\n                    return \"scale \" .. normalized\n                end\n            end\n        end\n    end\n\n    -- Bare scale name (e.g. just typing \"major\", \"minor\", \"natural minor\")\n    if _core and _core.solfegeScaleModes then\n        local bare = stripped:gsub(\"%s+\", \"_\")\n        if #bare >= 3 then\n            for _, mode in ipairs(_core.solfegeScaleModes) do\n                if mode:lower() == bare or mode:lower():find(bare, 1, true) then\n                    return \"scale \" .. bare\n                end\n            end\n        end\n    end\n\n    local octave = stripped:match(\"^set%s+octave%s+to%s+(%d+)$\")\n        or stripped:match(\"^set%s+the%s+octave%s+to%s+(%d+)$\")\n        or stripped:match(\"^set%s+oct%s+to%s+(%d+)$\")\n        or stripped:match(\"^change%s+octave%s+to%s+(%d+)$\")\n        or stripped:match(\"^change%s+the%s+octave%s+to%s+(%d+)$\")\n        or stripped:match(\"^change%s+oct%s+to%s+(%d+)$\")\n        or stripped:match(\"^use%s+octave%s+(%d+)$\")\n        or stripped:match(\"^use%s+oct%s+(%d+)$\")\n        or stripped:match(\"^make%s+octave%s+(%d+)$\")\n        or stripped:match(\"^make%s+oct%s+(%d+)$\")\n        or stripped:match(\"^make%s+it%s+octave%s+(%d+)$\")\n        or stripped:match(\"^make%s+it%s+oct%s+(%d+)$\")\n    if octave then\n        return \"octave \" .. octave\n    end\n    if isOneOf(stripped, {\"raise octave\", \"raise the octave\", \"octave up\", \"oct up\", \"higher octave\", \"higher oct\", \"move octave up\", \"move oct up\", \"change octave up\", \"change oct up\", \"change the octave up\", \"up an octave\", \"up an oct\", \"make it higher\"}) then\n        return \"octave up\"\n    end\n    if isOneOf(stripped, {\"lower octave\", \"lower oct\", \"octave down\", \"oct down\", \"lower the octave\", \"move octave down\", \"move oct down\", \"change octave down\", \"change oct down\", \"change the octave down\", \"down an octave\", \"down an oct\", \"make it lower\"}) then\n        return \"octave down\"\n    end\n\n    local patternOctave = stripped:match(\"^set%s+pattern%s+octave%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^set%s+pattern%s+oct%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^set%s+pat%s+oct%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^set%s+current%s+pattern%s+octave%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^set%s+current%s+pattern%s+oct%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^change%s+pattern%s+octave%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^change%s+pattern%s+oct%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^change%s+pat%s+oct%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^change%s+current%s+pattern%s+octave%s+to%s+([%+%-]?%d+)$\")\n        or stripped:match(\"^change%s+current%s+pattern%s+oct%s+to%s+([%+%-]?%d+)$\")\n    if patternOctave then\n        return \"pattern-octave \" .. patternOctave\n    end\n    if isOneOf(stripped, {\n        \"raise pattern octave\", \"raise the pattern octave\", \"pattern octave up\",\n        \"raise pattern oct\", \"raise the pattern oct\", \"pattern oct up\", \"pat oct up\",\n        \"move pattern octave up\", \"shift pattern up an octave\",\n        \"move pattern oct up\", \"shift pattern up an oct\",\n        \"shift current pattern up an octave\", \"move this pattern up an octave\"\n    }) then\n        return \"pattern-octave up\"\n    end\n    if isOneOf(stripped, {\n        \"lower pattern octave\", \"lower the pattern octave\", \"pattern octave down\",\n        \"lower pattern oct\", \"lower the pattern oct\", \"pattern oct down\", \"pat oct down\",\n        \"move pattern octave down\", \"shift pattern down an octave\",\n        \"move pattern oct down\", \"shift pattern down an oct\",\n        \"shift current pattern down an octave\", \"move this pattern down an octave\"\n    }) then\n        return \"pattern-octave down\"\n    end\n\n    local mode = stripped:match(\"^set%s+mode%s+to%s+(steps)$\")\n        or stripped:match(\"^set%s+mode%s+to%s+(lyrics)$\")\n        or stripped:match(\"^set%s+mode%s+to%s+(both)$\")\n        or stripped:match(\"^use%s+(steps)%s+mode$\")\n        or stripped:match(\"^use%s+(lyrics)%s+mode$\")\n        or stripped:match(\"^use%s+(both)%s+mode$\")\n    if mode then\n        return \"mode \" .. mode\n    end\n\n    local lyricText = text:match(\"^[Ss]et%s+the%s+words%s+to%s+(.+)$\")\n        or text:match(\"^[Ss]et%s+words%s+to%s+(.+)$\")\n        or text:match(\"^[Ss]et%s+lyrics%s+to%s+(.+)$\")\n        or text:match(\"^[Ss]et%s+the%s+lyrics%s+to%s+(.+)$\")\n        or text:match(\"^[Rr]eplace%s+lyrics%s+with%s+(.+)$\")\n        or text:match(\"^[Rr]eplace%s+the%s+lyrics%s+with%s+(.+)$\")\n        or text:match(\"^[Uu]se%s+these%s+lyrics%s+(.+)$\")\n        or text:match(\"^[Aa]dd%s+the%s+words%s+(.+)$\")\n    if lyricText then\n        return \"lyrics \" .. trim(lyricText)\n    end\n    local addLyricText = text:match(\"^[Aa]dd%s+lyrics%s+(.+)$\")\n        or text:match(\"^[Aa]ppend%s+lyrics%s+(.+)$\")\n        or text:match(\"^[Aa]ppend%s+words%s+(.+)$\")\n    if addLyricText then\n        return \"lyrics add \" .. trim(addLyricText)\n    end\n    local lyricNotesText = text:match(\"^[Mm]ake%s+lyric%s+notes%s+(.+)$\")\n        or text:match(\"^[Ss]et%s+lyric%s+notes%s+to%s+(.+)$\")\n    if lyricNotesText then\n        return \"lyrics notes \" .. trim(lyricNotesText)\n    end\n\n    local projectName = text:match(\"^[Rr]ename%s+project%s+to%s+(.+)$\")\n        or text:match(\"^[Cc]all%s+this%s+project%s+(.+)$\")\n    if projectName then\n        return \"project name \" .. trim(projectName)\n    end\n\n    local saveName = text:match(\"^[Ss]ave%s+this%s+as%s+(.+)$\")\n        or text:match(\"^[Ss]ave%s+current%s+as%s+(.+)$\")\n        or text:match(\"^[Rr]emember%s+this%s+as%s+(.+)$\")\n    if saveName then\n        return \"save current as \" .. trim(saveName)\n    end\n\n    local viewVerb, viewTarget = stripped:match(\"^(show)%s+(.+)$\")\n    if not viewVerb then\n        viewVerb, viewTarget = stripped:match(\"^(hide)%s+(.+)$\")\n    end\n    if viewVerb and viewTarget then\n        viewTarget = viewTarget:gsub(\"^the%s+\", \"\")\n        if isOneOf(viewTarget, {\"note length\", \"note lengths\", \"length\", \"lengths\"}) then\n            return \"view note-lengths \" .. viewVerb\n        elseif isOneOf(viewTarget, {\"octave\", \"octaves\", \"octave number\", \"octave numbers\"}) then\n            return \"view octaves \" .. viewVerb\n        end\n    end\n\n    local toggleViewTarget = stripped:match(\"^toggle%s+(.+)$\")\n    if toggleViewTarget then\n        toggleViewTarget = toggleViewTarget:gsub(\"^the%s+\", \"\")\n        if isOneOf(toggleViewTarget, {\"note length\", \"note lengths\", \"length\", \"lengths\"}) then\n            return \"view note-lengths toggle\"\n        elseif isOneOf(toggleViewTarget, {\"octave\", \"octaves\", \"octave number\", \"octave numbers\"}) then\n            return \"view octaves toggle\"\n        end\n    end\n\n    local templatePhrase = text:match(\"^[Ss]mart%s+template%s+(.+)$\")\n        or text:match(\"^[Tt]emplate%s+based%s+on%s+(.+)$\")\n        or text:match(\"^[Ff]ind%s+template%s+for%s+(.+)$\")\n    if templatePhrase and parseSolfegeChatTokens(templatePhrase) then\n        return \"template \" .. templatePhrase\n    end\n\n    local chordPhrase = text:match(\"^[Aa]dd%s+a%s+chord%s+(.+)$\")\n        or text:match(\"^[Aa]dd%s+chord%s+(.+)$\")\n        or text:match(\"^[Pp]ut%s+in%s+a%s+chord%s+(.+)$\")\n        or text:match(\"^[Pp]ut%s+in%s+chord%s+(.+)$\")\n    if chordPhrase and chordTextFromPhrase(chordPhrase) then\n        return \"chord add \" .. chordPhrase\n    end\n\n    chordPhrase = text:match(\"^[Pp]lay%s+a%s+chord%s+(.+)$\")\n        or text:match(\"^[Pp]lay%s+chord%s+(.+)$\")\n        or text:match(\"^[Hh]ear%s+a%s+chord%s+(.+)$\")\n        or text:match(\"^[Hh]ear%s+chord%s+(.+)$\")\n        or text:match(\"^[Ll]isten%s+to%s+a%s+chord%s+(.+)$\")\n        or text:match(\"^[Ll]isten%s+to%s+chord%s+(.+)$\")\n    if chordPhrase and chordTextFromPhrase(chordPhrase) then\n        return \"chord play \" .. chordPhrase\n    end\n\n    chordPhrase = text:match(\"^[Ss]et%s+a%s+chord%s+(.+)$\")\n        or text:match(\"^[Ss]et%s+chord%s+(.+)$\")\n        or text:match(\"^[Rr]eplace%s+with%s+a%s+chord%s+(.+)$\")\n        or text:match(\"^[Rr]eplace%s+with%s+chord%s+(.+)$\")\n    if chordPhrase and chordTextFromPhrase(chordPhrase) then\n        return \"chord set \" .. chordPhrase\n    end\n\n    local phrase = text:match(\"^[Pp]ut%s+in%s+(.+)$\")\n        or text:match(\"^[Pp]ut%s+(.+)%s+in%s+the%s+melody$\")\n        or text:match(\"^[Aa]dd%s+the%s+notes%s+(.+)$\")\n        or text:match(\"^[Aa]dd%s+notes%s+(.+)$\")\n        or text:match(\"^[Aa]dd%s+(.+)%s+to%s+this$\")\n        or text:match(\"^[Aa]dd%s+(.+)%s+to%s+the%s+text%s+box$\")\n        or text:match(\"^[Aa]ppend%s+(.+)%s+to%s+the%s+text%s+box$\")\n        or text:match(\"^[Aa]ppend%s+the%s+notes%s+(.+)$\")\n        or text:match(\"^[Aa]ppend%s+notes%s+(.+)$\")\n        or text:match(\"^[Aa]ppend%s+(.+)%s+to%s+the%s+melody$\")\n        or text:match(\"^[Aa]dd%s+(.+)%s+to%s+the%s+melody$\")\n    if phrase and parseSolfegeChatTokens(phrase) then\n        return \"add \" .. phrase\n    end\n\n    local playPhrase = text:match(\"^[Pp]lay%s+the%s+notes%s+(.+)$\")\n        or text:match(\"^[Pp]lay%s+(.+)%s+for%s+me$\")\n        or text:match(\"^[Pp]lay%s+(.+)%s+without%s+adding%s+it$\")\n        or text:match(\"^[Pp]review%s+(.+)$\")\n        or text:match(\"^[Hh]ear%s+the%s+notes%s+(.+)$\")\n        or text:match(\"^[Hh]ear%s+(.+)%s+without%s+adding%s+it$\")\n        or text:match(\"^[Ll]isten%s+to%s+(.+)$\")\n    if playPhrase and parseSolfegeChatTokens(playPhrase) then\n        return \"hear \" .. playPhrase\n    end\n\n    local repeatedPhrase, countRaw = text:match(\"^[Rr]epeat%s+(.+)%s+([%w%-']+)%s+times$\")\n    if repeatedPhrase and countRaw then\n        local count = naturalCount(countRaw)\n        if count and parseSolfegeChatTokens(repeatedPhrase) then\n            return \"repeat \" .. count .. \" \" .. repeatedPhrase\n        end\n    end\n    repeatedPhrase, countRaw = text:match(\"^[Rr]epeat%s+(.+)%s+([%w%-']+)$\")\n    if repeatedPhrase and countRaw then\n        local count = naturalCount(countRaw)\n        if count and parseSolfegeChatTokens(repeatedPhrase) then\n            return \"repeat \" .. count .. \" \" .. repeatedPhrase\n        end\n    end\n\n    local durationRaw, durationPhrase = text:match(\"^[Uu]se%s+(.+)%s+for%s+(.+)$\")\n    if durationRaw and durationPhrase\n       and (durationRaw:lower():match(\"notes$\") or durationRaw:lower():match(\"note$\"))\n       and parseSolfegeChatTokens(durationPhrase) then\n        local duration = naturalDuration(durationRaw)\n        if duration then\n            return \"rhythm \" .. duration .. \" \" .. durationPhrase\n        end\n    end\n\n    durationRaw = text:match(\"^[Mm]ake%s+this%s+(.+)%s+notes$\")\n        or text:match(\"^[Mm]ake%s+current%s+(.+)%s+notes$\")\n        or text:match(\"^[Mm]ake%s+the%s+melody%s+(.+)%s+notes$\")\n        or text:match(\"^[Ss]et%s+this%s+to%s+(.+)%s+notes$\")\n        or text:match(\"^[Ss]et%s+current%s+to%s+(.+)%s+notes$\")\n        or text:match(\"^[Ss]et%s+the%s+melody%s+to%s+(.+)%s+notes$\")\n    if durationRaw then\n        local duration = naturalDuration(durationRaw)\n        if duration then\n            return \"rhythm \" .. duration\n        end\n    end\n\n    local lengthRaw = stripped:match(\"^set%s+default%s+length%s+to%s+(.+)$\")\n        or stripped:match(\"^set%s+note%s+length%s+to%s+(.+)$\")\n        or stripped:match(\"^use%s+(.+)%s+notes%s+by%s+default$\")\n    if lengthRaw then\n        local duration = naturalDuration(lengthRaw)\n        if duration then\n            return \"length \" .. duration\n        end\n    end\n\n    if isOneOf(stripped, {\"reverse this\", \"reverse current\", \"reverse the current melody\", \"reverse the melody\", \"reverse melody\", \"turn the melody around\"}) then\n        return \"reverse\"\n    end\n\n    local fromSyllable, toSyllable = text:match(\"^[Cc]hange%s+every%s+(%S+)%s+to%s+(%S+)$\")\n    if not fromSyllable then\n        fromSyllable, toSyllable = text:match(\"^[Cc]hange%s+all%s+(%S+)%s+to%s+(%S+)$\")\n    end\n    if not fromSyllable then\n        fromSyllable, toSyllable = text:match(\"^[Rr]eplace%s+every%s+(%S+)%s+with%s+(%S+)$\")\n    end\n    if not fromSyllable then\n        fromSyllable, toSyllable = text:match(\"^[Rr]eplace%s+all%s+(%S+)%s+with%s+(%S+)$\")\n    end\n    if fromSyllable and canonicalSolfegeSyllable(fromSyllable) and canonicalSolfegeSyllable(toSyllable) then\n        return \"change \" .. fromSyllable .. \" to \" .. toSyllable\n    end\n\n    local swapLeft, swapRight = text:match(\"^[Ss]wap%s+(%S+)%s+and%s+(%S+)$\")\n    if not swapLeft then\n        swapLeft, swapRight = text:match(\"^[Ss]wap%s+(%S+)%s+(%S+)$\")\n    end\n    if swapLeft and canonicalSolfegeSyllable(swapLeft) and canonicalSolfegeSyllable(swapRight) then\n        return \"swap \" .. swapLeft .. \" \" .. swapRight\n    end\n\n    local shiftRange, shiftDir, shiftAmount = text:match(\"^[Mm]ove%s+notes?%s+(.+)%s+(up)%s*(%d*)$\")\n    if not shiftRange then\n        shiftRange, shiftDir, shiftAmount = text:match(\"^[Mm]ove%s+notes?%s+(.+)%s+(down)%s*(%d*)$\")\n    end\n    if not shiftRange then\n        shiftRange, shiftDir, shiftAmount = text:match(\"^[Rr]aise%s+notes?%s+(.+)%s*(%d*)$\")\n        if shiftRange then shiftDir = \"up\" end\n    end\n    if not shiftRange then\n        shiftRange, shiftDir, shiftAmount = text:match(\"^[Ll]ower%s+notes?%s+(.+)%s*(%d*)$\")\n        if shiftRange then shiftDir = \"down\" end\n    end\n    if not shiftRange then\n        shiftRange, shiftDir, shiftAmount = text:match(\"^[Cc]hange%s+notes?%s+(.+)%s+(up)%s*(%d*)$\")\n    end\n    if not shiftRange then\n        shiftRange, shiftDir, shiftAmount = text:match(\"^[Cc]hange%s+notes?%s+(.+)%s+(down)%s*(%d*)$\")\n    end\n    if shiftRange and shiftDir then\n        local range = naturalRange(shiftRange)\n        if range then\n            return \"change \" .. range .. \" \" .. shiftDir .. ((shiftAmount and shiftAmount ~= \"\") and (\" \" .. shiftAmount) or \"\")\n        end\n    end\n\n    local replaceRange, replacePhrase = text:match(\"^[Cc]hange%s+notes?%s+(.+)%s+to%s+(.+)$\")\n    if not replaceRange then\n        replaceRange, replacePhrase = text:match(\"^[Rr]eplace%s+notes?%s+(.+)%s+with%s+(.+)$\")\n    end\n    if not replaceRange then\n        replaceRange, replacePhrase = text:match(\"^[Ss]et%s+notes?%s+(.+)%s+to%s+(.+)$\")\n    end\n    if not replaceRange then\n        replaceRange, replacePhrase = text:match(\"^[Cc]hange%s+steps?%s+(.+)%s+to%s+(.+)$\")\n    end\n    if not replaceRange then\n        replaceRange, replacePhrase = text:match(\"^[Rr]eplace%s+steps?%s+(.+)%s+with%s+(.+)$\")\n    end\n    if replaceRange and replacePhrase and parseSolfegeChatTokens(replacePhrase) then\n        local range = naturalRange(replaceRange)\n        if range then\n            return \"replace \" .. range .. \" \" .. replacePhrase\n        end\n    end\n\n    local insertPhrase, insertIndex = text:match(\"^[Ii]nsert%s+(.+)%s+before%s+steps?%s+(%d+)$\")\n    if not insertPhrase then\n        insertPhrase, insertIndex = text:match(\"^[Pp]ut%s+(.+)%s+before%s+steps?%s+(%d+)$\")\n    end\n    if insertPhrase and insertIndex and parseSolfegeChatTokens(insertPhrase) then\n        return \"insert \" .. insertIndex .. \" \" .. insertPhrase\n    end\n\n    insertPhrase = text:match(\"^[Aa]dd%s+(.+)%s+at%s+the%s+end$\")\n        or text:match(\"^[Pp]ut%s+(.+)%s+at%s+the%s+end$\")\n    if insertPhrase and parseSolfegeChatTokens(insertPhrase) then\n        return \"insert end \" .. insertPhrase\n    end\n\n    local deleteRange = text:match(\"^[Dd]elete%s+(.+)$\")\n        or text:match(\"^[Rr]emove%s+(.+)$\")\n        or text:match(\"^[Tt]ake%s+out%s+(.+)$\")\n    if deleteRange then\n        local range = naturalRange(deleteRange)\n        if range then\n            return \"delete \" .. range\n        end\n    end\n\n    local patternKind = stripped:match(\"^make%s+(.+)%s+melody$\")\n        or stripped:match(\"^make%s+(.+)%s+line$\")\n        or stripped:match(\"^give%s+me%s+(.+)%s+melody$\")\n    if patternKind then\n        patternKind = patternKind:gsub(\"^an%s+\", \"\"):gsub(\"^a%s+\", \"\")\n    end\n    if patternKind then\n        local mapped = {\n            rising = \"rise\",\n            ascending = \"rise\",\n            falling = \"fall\",\n            descending = \"fall\",\n            arch = \"arch\",\n            bass = \"bass\",\n            random = \"random\",\n            pentatonic = \"pent\",\n            thirds = \"thirds\",\n            [\"thirds-based\"] = \"thirds\",\n        }\n        if mapped[patternKind] then\n            return \"make \" .. mapped[patternKind] .. \" 8\"\n        end\n    end\n\n    local countRaw, countedKind = stripped:match(\"^give%s+me%s+([%w%-']+)%s+(.+)%s+notes$\")\n    if countRaw and countedKind then\n        local count = naturalCount(countRaw)\n        countedKind = countedKind:gsub(\"^an%s+\", \"\"):gsub(\"^a%s+\", \"\")\n        local mapped = {\n            random = \"random\",\n            rising = \"rise\",\n            ascending = \"rise\",\n            falling = \"fall\",\n            descending = \"fall\",\n            pentatonic = \"pent\",\n        }\n        if count and mapped[countedKind] then\n            return \"make \" .. mapped[countedKind] .. \" \" .. count\n        end\n    end\n\n    return nil\nend\n\nlocal function executeCommandText(commandText)\n    local cmd, argStr = commandText:match(\"^/?(%S+)%s*(.*)\")\n    cmd = resolveCommandName(cmd)\n    local entry = commands[cmd]\n    if not entry then\n        local suggestion = closestCommandName(cmd)\n        if suggestion and commands[suggestion] then\n            return {\n                role = \"system\",\n                text = \"Unknown command: \" .. cmd .. \". Did you mean '\" .. suggestion .. \"'?\\n\"\n                    .. commands[suggestion].usage\n            }\n        end\n        return {role = \"system\", text = \"Unknown command: \" .. cmd .. \". Type 'help' for examples.\"}\n    end\n    local ok, result = pcall(entry.handler, argStr or \"\")\n    if not ok then\n        return {role = \"error\", text = \"Error: \" .. tostring(result)}\n    end\n    if type(result) == \"string\" then\n        return {role = \"system\", text = result}\n    end\n    return nil\nend\n\naddCommand(\"open\", \"open\", \"Open a MusicXML or MIDI file (web)\", function()\n    local picker = rawget(_G, \"_webPickFile\")\n    if picker then\n        picker()\n        return \"Opening file picker...\"\n    end\n    return \"File open is only available in the web version\"\nend)\n\naddCommand(\"export\", \"export\", \"Download project as MusicXML file (web)\", function()\n    if not _actions.exportMusicXML then\n        return \"Export not available\"\n    end\n    local filename, xml = _actions.exportMusicXML()\n    if not filename then\n        return \"Failed to export: \" .. tostring(xml)\n    end\n    local host = rawget(_G, \"WebHost\")\n    if host and host.downloadFile then\n        host.downloadFile(filename, xml, \"application/vnd.recordare.musicxml+xml\")\n        return \"Downloading \" .. filename\n    end\n    return \"Export is only available in the web version\"\nend)\n\naddAlias(\"import\", \"open\")\naddAlias(\"?\", \"help\")\naddAlias(\"about\", \"ask\")\naddAlias(\"commands\", \"help\")\naddAlias(\"examples\", \"help\")\naddAlias(\"status\", \"info\")\naddAlias(\"config\", \"settings\")\naddAlias(\"vol\", \"volume\")\naddAlias(\"display\", \"view\")\naddAlias(\"visibility\", \"view\")\naddAlias(\"u\", \"undo\")\naddAlias(\"z\", \"undo\")\naddAlias(\"re\", \"redo\")\naddAlias(\"h\", \"hear\")\naddAlias(\"listen\", \"hear\")\naddAlias(\"a\", \"add\")\naddAlias(\"append\", \"add\")\naddAlias(\"p\", \"play\")\naddAlias(\"s\", \"stop\")\naddAlias(\"q\", \"stop\")\naddAlias(\"continue\", \"resume\")\naddAlias(\"l\", \"loop\")\naddAlias(\"code\", \"live\")\naddAlias(\"go\", \"live\")\naddAlias(\"ch\", \"chord\")\naddAlias(\"r\", \"repeat\")\naddAlias(\"compose\", \"make\")\naddAlias(\"gen\", \"make\")\naddAlias(\"rev\", \"reverse\")\naddAlias(\"rot\", \"rotate\")\naddAlias(\"dur\", \"rhythm\")\naddAlias(\"len\", \"length\")\naddAlias(\"note\", \"length\")\naddAlias(\"rep\", \"replace\")\naddAlias(\"ins\", \"insert\")\naddAlias(\"del\", \"delete\")\naddAlias(\"remove\", \"delete\")\naddAlias(\"fast\", \"faster\")\naddAlias(\"speedup\", \"faster\")\naddAlias(\"slow\", \"slower\")\naddAlias(\"slowdown\", \"slower\")\naddAlias(\"store\", \"save\")\naddAlias(\"remember\", \"save\")\naddAlias(\"melody\", \"save\")\naddAlias(\"lyric\", \"lyrics\")\naddAlias(\"words\", \"lyrics\")\naddAlias(\"name\", \"project\")\naddAlias(\"tempo\", \"bpm\")\naddAlias(\"o\", \"octave\")\naddAlias(\"oct\", \"octave\")\naddAlias(\"octv\", \"octave\")\naddAlias(\"octive\", \"octave\")\naddAlias(\"po\", \"pattern-octave\")\naddAlias(\"poct\", \"pattern-octave\")\naddAlias(\"pat-oct\", \"pattern-octave\")\naddAlias(\"pat-octv\", \"pattern-octave\")\naddAlias(\"pattern-oct\", \"pattern-octave\")\naddAlias(\"pattern-octv\", \"pattern-octave\")\naddAlias(\"pattern-octive\", \"pattern-octave\")\naddAlias(\"tpl\", \"templates\")\naddAlias(\"tmpl\", \"template\")\naddAlias(\"use\", \"template\")\naddAlias(\"prev\", \"preview\")\naddAlias(\"acappella\", \"sound\")\naddAlias(\"a-cappella\", \"sound\")\naddAlias(\"stream\", \"sound\")\naddAlias(\"train\", \"training\")\naddAlias(\"ps\", \"playback-stop\")\naddAlias(\"autostop\", \"playback-stop\")\naddAlias(\"auto-stop\", \"playback-stop\")\naddAlias(\"pat\", \"pattern\")\naddAlias(\"patterns\", \"pattern\")\naddAlias(\"default\", \"default-mode\")\naddAlias(\"prefs\", \"settings\")\naddAlias(\"preferences\", \"settings\")\n\nfunction cmdChat.init(state, core, tl, actions)\n    _state = state\n    _core = core\n    _tl = tl\n    _actions = actions or {}\nend\n\nfunction cmdChat.execute(inputText)\n    local trimmed = trim(inputText)\n    if #trimmed == 0 then return nil end\n    local directSolfegeResult = handleSolfegeText(trimmed, true, true, true)\n    if directSolfegeResult then\n        return {role = \"system\", text = directSolfegeResult}\n    end\n\n    local naturalCommand = parseNaturalIntent(trimmed)\n    if naturalCommand then\n        return executeCommandText(naturalCommand)\n    end\n\n    return executeCommandText(trimmed)\nend\n\nfunction cmdChat.welcomeMessage()\n    return {role = \"system\", text = \"Type 'help' for commands, or type solfege like 'do re mi' to add and play it. Use Up/Down to recall chat commands.\"}\nend\n\nreturn cmdChat\n","ear_training.lua":"local earTraining = {}\n\nlocal INTERVAL_NAMES = {\n    [0]  = \"Unison\",\n    [1]  = \"Minor 2nd\",\n    [2]  = \"Major 2nd\",\n    [3]  = \"Minor 3rd\",\n    [4]  = \"Major 3rd\",\n    [5]  = \"Perfect 4th\",\n    [6]  = \"Tritone\",\n    [7]  = \"Perfect 5th\",\n    [8]  = \"Minor 6th\",\n    [9]  = \"Major 6th\",\n    [10] = \"Minor 7th\",\n    [11] = \"Major 7th\",\n    [12] = \"Octave\",\n}\n\nlocal INTERVAL_SHORT = {\n    [0]  = \"P1\",\n    [1]  = \"m2\",\n    [2]  = \"M2\",\n    [3]  = \"m3\",\n    [4]  = \"M3\",\n    [5]  = \"P4\",\n    [6]  = \"TT\",\n    [7]  = \"P5\",\n    [8]  = \"m6\",\n    [9]  = \"M6\",\n    [10] = \"m7\",\n    [11] = \"M7\",\n    [12] = \"P8\",\n}\n\nlocal CHORD_TYPES = {\n    { name = \"Major\",      intervals = {0, 4, 7},  solfege = \"Do Mi Sol\" },\n    { name = \"Minor\",      intervals = {0, 3, 7},  solfege = \"Do Me Sol\" },\n    { name = \"Diminished\", intervals = {0, 3, 6},  solfege = \"Do Me Se\" },\n    { name = \"Augmented\",  intervals = {0, 4, 8},  solfege = \"Do Mi Si\" },\n}\n\nlocal SCALE_TYPES = {\n    { name = \"Major\",          intervals = {0, 2, 4, 5, 7, 9, 11, 12} },\n    { name = \"Natural Minor\",  intervals = {0, 2, 3, 5, 7, 8, 10, 12} },\n    { name = \"Harmonic Minor\", intervals = {0, 2, 3, 5, 7, 8, 11, 12} },\n    { name = \"Dorian\",         intervals = {0, 2, 3, 5, 7, 9, 10, 12} },\n    { name = \"Pentatonic\",     intervals = {0, 2, 4, 7, 9, 12} },\n    { name = \"Blues\",           intervals = {0, 3, 5, 6, 7, 10, 12} },\n}\n\nlocal SOLFEGE_BY_INTERVAL = {\n    [0]  = \"Do\",\n    [1]  = \"Di\",\n    [2]  = \"Re\",\n    [3]  = \"Me\",\n    [4]  = \"Mi\",\n    [5]  = \"Fa\",\n    [6]  = \"Fi\",\n    [7]  = \"Sol\",\n    [8]  = \"Le\",\n    [9]  = \"La\",\n    [10] = \"Te\",\n    [11] = \"Ti\",\n    [12] = \"Do'\",\n}\n\nlocal DIFFICULTY_SETTINGS = {\n    {\n        label = \"Easy\",\n        intervals = {3, 4, 5, 7, 12},\n        chords = {1, 2},\n        scales = {1, 2},\n        dictationLength = 3,\n    },\n    {\n        label = \"Medium\",\n        intervals = {1, 2, 3, 4, 5, 7, 8, 9, 12},\n        chords = {1, 2, 3},\n        scales = {1, 2, 3, 4},\n        dictationLength = 4,\n    },\n    {\n        label = \"Hard\",\n        intervals = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},\n        chords = {1, 2, 3, 4},\n        scales = {1, 2, 3, 4, 5, 6},\n        dictationLength = 5,\n    },\n}\n\nlocal function pickRandom(tbl)\n    return tbl[math.random(#tbl)]\nend\n\nlocal function shuffle(tbl)\n    local n = #tbl\n    for i = n, 2, -1 do\n        local j = math.random(i)\n        tbl[i], tbl[j] = tbl[j], tbl[i]\n    end\n    return tbl\nend\n\nlocal function generateDistractors(correct, allOptions, count)\n    local pool = {}\n    for _, opt in ipairs(allOptions) do\n        if opt ~= correct then\n            pool[#pool + 1] = opt\n        end\n    end\n    shuffle(pool)\n    local result = {correct}\n    for i = 1, math.min(count, #pool) do\n        result[#result + 1] = pool[i]\n    end\n    shuffle(result)\n    return result\nend\n\nfunction earTraining.generateQuestion(exerciseType, difficulty)\n    math.randomseed(os.time() + math.random(1000))\n    local diff = DIFFICULTY_SETTINGS[difficulty] or DIFFICULTY_SETTINGS[1]\n\n    if exerciseType == \"interval\" then\n        return earTraining._generateIntervalQuestion(diff)\n    elseif exerciseType == \"chord\" then\n        return earTraining._generateChordQuestion(diff)\n    elseif exerciseType == \"scale\" then\n        return earTraining._generateScaleQuestion(diff)\n    elseif exerciseType == \"dictation\" then\n        return earTraining._generateDictationQuestion(diff)\n    end\n    return nil\nend\n\nfunction earTraining._generateIntervalQuestion(diff)\n    local semitones = pickRandom(diff.intervals)\n    local rootNote = math.random(0, 5)\n    local rootOctave = 4\n    local ascending = math.random() > 0.3\n\n    local secondNote, secondOctave\n    if ascending then\n        secondNote = rootNote + semitones\n        secondOctave = rootOctave\n        while secondNote > 12 do\n            secondNote = secondNote - 12\n            secondOctave = secondOctave + 1\n        end\n    else\n        secondNote = rootNote - semitones\n        secondOctave = rootOctave\n        while secondNote < 0 do\n            secondNote = secondNote + 12\n            secondOctave = secondOctave - 1\n        end\n    end\n\n    local allIntervalNames = {}\n    for _, s in ipairs(diff.intervals) do\n        allIntervalNames[#allIntervalNames + 1] = INTERVAL_NAMES[s]\n    end\n\n    local answer = INTERVAL_NAMES[semitones]\n    local options = generateDistractors(answer, allIntervalNames, 3)\n\n    return {\n        type = \"interval\",\n        prompt = ascending and \"Ascending interval\" or \"Descending interval\",\n        notes = {\n            {note = rootNote, octave = rootOctave},\n            {note = secondNote, octave = secondOctave},\n        },\n        answer = answer,\n        answerDetail = INTERVAL_SHORT[semitones] .. \" (\" .. semitones .. \" semitones)\",\n        options = options,\n        ascending = ascending,\n    }\nend\n\nfunction earTraining._generateChordQuestion(diff)\n    local chordIdx = pickRandom(diff.chords)\n    local chord = CHORD_TYPES[chordIdx]\n    local rootNote = math.random(0, 7)\n    local rootOctave = 4\n\n    local notes = {}\n    for _, interval in ipairs(chord.intervals) do\n        local n = rootNote + interval\n        local o = rootOctave\n        while n > 12 do\n            n = n - 12\n            o = o + 1\n        end\n        notes[#notes + 1] = {note = n, octave = o}\n    end\n\n    local allChordNames = {}\n    for _, ci in ipairs(diff.chords) do\n        allChordNames[#allChordNames + 1] = CHORD_TYPES[ci].name\n    end\n\n    local answer = chord.name\n    local options = generateDistractors(answer, allChordNames, 3)\n\n    return {\n        type = \"chord\",\n        prompt = \"What chord quality?\",\n        notes = notes,\n        playAsChord = true,\n        answer = answer,\n        answerDetail = chord.solfege,\n        options = options,\n    }\nend\n\nfunction earTraining._generateScaleQuestion(diff)\n    local scaleIdx = pickRandom(diff.scales)\n    local scale = SCALE_TYPES[scaleIdx]\n    local rootNote = math.random(0, 5)\n    local rootOctave = 4\n\n    local notes = {}\n    for _, interval in ipairs(scale.intervals) do\n        local n = rootNote + interval\n        local o = rootOctave\n        while n > 12 do\n            n = n - 12\n            o = o + 1\n        end\n        notes[#notes + 1] = {note = n, octave = o}\n    end\n\n    local allScaleNames = {}\n    for _, si in ipairs(diff.scales) do\n        allScaleNames[#allScaleNames + 1] = SCALE_TYPES[si].name\n    end\n\n    local answer = scale.name\n    local options = generateDistractors(answer, allScaleNames, 3)\n\n    local solfegePreview = {}\n    for _, interval in ipairs(scale.intervals) do\n        solfegePreview[#solfegePreview + 1] = SOLFEGE_BY_INTERVAL[interval] or \"?\"\n    end\n\n    return {\n        type = \"scale\",\n        prompt = \"What scale?\",\n        notes = notes,\n        playAsSequence = true,\n        answer = answer,\n        answerDetail = table.concat(solfegePreview, \" \"),\n        options = options,\n    }\nend\n\nfunction earTraining._generateDictationQuestion(diff)\n    local len = diff.dictationLength\n    local diatonicNotes = {0, 2, 4, 5, 7, 9, 11}\n    local rootOctave = 4\n\n    local melody = {}\n    local solfege = {}\n    local prevNote = 0\n    for i = 1, len do\n        local noteInterval\n        if i == 1 then\n            noteInterval = 0\n        else\n            local candidates = {}\n            for _, n in ipairs(diatonicNotes) do\n                if math.abs(n - prevNote) <= 5 then\n                    candidates[#candidates + 1] = n\n                end\n            end\n            if #candidates == 0 then candidates = diatonicNotes end\n            noteInterval = pickRandom(candidates)\n        end\n        prevNote = noteInterval\n        local n = noteInterval\n        local o = rootOctave\n        melody[#melody + 1] = {note = n, octave = o}\n        solfege[#solfege + 1] = SOLFEGE_BY_INTERVAL[noteInterval] or \"?\"\n    end\n\n    return {\n        type = \"dictation\",\n        prompt = \"Write the melody (\" .. len .. \" notes)\",\n        notes = melody,\n        playAsSequence = true,\n        answer = table.concat(solfege, \" \"),\n        answerSolfege = solfege,\n        options = nil,\n    }\nend\n\nfunction earTraining.checkDictationAnswer(question, userInput)\n    if not question or not question.answerSolfege then return false, 0 end\n    local expected = question.answerSolfege\n    local userNotes = {}\n    for word in userInput:gmatch(\"%S+\") do\n        userNotes[#userNotes + 1] = word:lower()\n    end\n\n    local correct = 0\n    local total = #expected\n    for i = 1, total do\n        local exp = (expected[i] or \"\"):lower():gsub(\"'\", \"\")\n        local got = (userNotes[i] or \"\"):lower():gsub(\"'\", \"\")\n        if exp == got then\n            correct = correct + 1\n        end\n    end\n    return correct == total, correct\nend\n\nearTraining.INTERVAL_NAMES = INTERVAL_NAMES\nearTraining.INTERVAL_SHORT = INTERVAL_SHORT\nearTraining.CHORD_TYPES = CHORD_TYPES\nearTraining.SCALE_TYPES = SCALE_TYPES\nearTraining.SOLFEGE_BY_INTERVAL = SOLFEGE_BY_INTERVAL\nearTraining.DIFFICULTY_SETTINGS = DIFFICULTY_SETTINGS\nearTraining.EXERCISE_TYPES = {\"interval\", \"chord\", \"scale\", \"dictation\"}\nearTraining.EXERCISE_LABELS = {\n    interval = \"Intervals\",\n    chord = \"Chords\",\n    scale = \"Scales\",\n    dictation = \"Dictation\",\n}\n\nreturn earTraining\n","graphics_adapter.lua":"local GraphicsAdapter = {}\n\nGraphicsAdapter.DrawMode = {\n    COPY = \"copy\",\n    FILL_WHITE = \"fillWhite\",\n    FILL_BLACK = \"fillBlack\",\n    INVERTED = \"inverted\"\n}\n\nGraphicsAdapter.FontStyle = {\n    NORMAL = \"normal\",\n    BOLD = \"bold\"\n}\n\nGraphicsAdapter.Color = {\n    WHITE = \"white\",\n    BLACK = \"black\",\n    CLEAR = \"clear\"\n}\n\nlocal noop = function() end\nlocal noopWH = function() return 0, 0 end\nlocal noopNil = function() return nil end\n\nfunction GraphicsAdapter.new(implementation)\n    local impl = implementation\n    if impl.init then impl.init() end\n\n    local self = {\n        impl = impl,\n        clear          = impl.clear          or noop,\n        drawText       = impl.drawText       or noop,\n        drawRect       = impl.drawRect       or noop,\n        fillRect       = impl.fillRect       or noop,\n        drawRoundRect  = impl.drawRoundRect  or impl.drawRect or noop,\n        fillRoundRect  = impl.fillRoundRect  or impl.fillRect or noop,\n        drawLine       = impl.drawLine       or noop,\n        setFont        = impl.setFont        or noop,\n        getTextSize    = impl.getTextSize    or noopWH,\n        setDrawMode    = impl.setDrawMode    or noop,\n        setLineWidth   = impl.setLineWidth   or noop,\n        drawCircle     = impl.drawCircle     or noop,\n        fillCircle     = impl.fillCircle     or noop,\n        drawEllipseInRect = impl.drawEllipseInRect or noop,\n        drawPolygon    = impl.drawPolygon    or noop,\n        fillPolygon    = impl.fillPolygon    or noop,\n        loadImage      = impl.loadImage      or noopNil,\n        drawImage      = impl.drawImage      or noop,\n        drawImageScaled = impl.drawImageScaled or noop,\n        getImageSize   = impl.getImageSize   or noopWH,\n        setColor       = impl.setColor       or noop,\n        setDarkMode    = impl.setDarkMode    or noop,\n        update         = impl.update         or noop,\n        cleanup        = impl.cleanup        or noop,\n        getScreenWidth = impl.getScreenWidth or function() return 400 end,\n        getScreenHeight = impl.getScreenHeight or function() return 240 end,\n        kColorBlack = \"black\",\n        kColorWhite = \"white\",\n    }\n\n    return self\nend\n\nreturn GraphicsAdapter\n","input_adapter.lua":"-- input_adapter.lua\n-- Platform-agnostic input handling abstraction layer\n--\n-- This module provides a unified interface for handling input across different platforms.\n-- Platform-specific implementations should conform to this interface.\n\nlocal InputAdapter = {}\n\n-- Input action types (enum-like table)\nInputAdapter.Action = {\n    LEFT = \"left\",\n    RIGHT = \"right\",\n    UP = \"up\",\n    DOWN = \"down\",\n    PRIMARY = \"primary\",      -- A button on Playdate, Enter/Space on web\n    SECONDARY = \"secondary\",  -- B button on Playdate, Esc/Backspace on web\n    ANALOG = \"analog\"         -- Crank on Playdate, mouse wheel/slider on web\n}\n\n-- Button state (for checking if button is currently pressed)\nInputAdapter.Button = {\n    PRIMARY = \"primary\",\n    SECONDARY = \"secondary\",\n    LEFT = \"left\",\n    RIGHT = \"right\",\n    UP = \"up\",\n    DOWN = \"down\"\n}\n\n--[[\n    Creates a new input adapter instance\n\n    @param implementation table - Platform-specific implementation with the following methods:\n        - init(callbacks) - Initialize input system with callback table\n        - isButtonPressed(button) - Check if a button is currently pressed\n        - update() - Update input state (called each frame)\n        - cleanup() - Cleanup resources\n\n    @param callbacks table - Input event callbacks:\n        - onLeft() - Called when left input is triggered\n        - onRight() - Called when right input is triggered\n        - onUp() - Called when up input is triggered\n        - onDown() - Called when down input is triggered\n        - onPrimary() - Called when primary action is triggered\n        - onSecondary() - Called when secondary action is triggered\n        - onAnalog(delta, acceleratedDelta) - Called when analog input changes\n\n    @return table - Input adapter instance\n]]\nfunction InputAdapter.new(implementation, callbacks)\n    local self = {\n        impl = implementation,\n        callbacks = callbacks or {}\n    }\n\n    -- Initialize the platform-specific implementation\n    if self.impl.init then\n        self.impl.init(self.callbacks)\n    end\n\n    --[[\n        Check if a button is currently pressed\n        @param button string - Button identifier from InputAdapter.Button\n        @return boolean - True if button is pressed\n    ]]\n    function self.isButtonPressed(button)\n        if self.impl.isButtonPressed then\n            return self.impl.isButtonPressed(button)\n        end\n        return false\n    end\n\n    --[[\n        Update input state (should be called each frame)\n    ]]\n    function self.update()\n        if self.impl.update then\n            self.impl.update()\n        end\n    end\n\n    --[[\n        Cleanup input system resources\n    ]]\n    function self.cleanup()\n        if self.impl.cleanup then\n            self.impl.cleanup()\n        end\n    end\n\n    --[[\n        Set or update a specific callback\n        @param action string - Action identifier from InputAdapter.Action\n        @param callback function - Callback function to set\n    ]]\n    function self.setCallback(action, callback)\n        if action == InputAdapter.Action.LEFT then\n            self.callbacks.onLeft = callback\n        elseif action == InputAdapter.Action.RIGHT then\n            self.callbacks.onRight = callback\n        elseif action == InputAdapter.Action.UP then\n            self.callbacks.onUp = callback\n        elseif action == InputAdapter.Action.DOWN then\n            self.callbacks.onDown = callback\n        elseif action == InputAdapter.Action.PRIMARY then\n            self.callbacks.onPrimary = callback\n        elseif action == InputAdapter.Action.SECONDARY then\n            self.callbacks.onSecondary = callback\n        elseif action == InputAdapter.Action.ANALOG then\n            self.callbacks.onAnalog = callback\n        end\n\n        -- Update implementation if it supports dynamic callback updates\n        if self.impl.updateCallbacks then\n            self.impl.updateCallbacks(self.callbacks)\n        end\n    end\n\n    return self\nend\n\nreturn InputAdapter\n","lyrics_import.lua":"local LyricsImport = {}\nlocal MAX_IMPORT_STEPS = 64\n\nlocal SOLFEGE_NOTES_BY_SCALE = {\n    major = {\"Do\", \"Di\", \"Re\", \"Ri\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\", \"Rest\"},\n    natural_minor = {\"Do\", \"Ra\", \"Re\", \"Me\", \"Mi\", \"Fa\", \"Se\", \"Sol\", \"Le\", \"La\", \"Te\", \"Ti\", \"Do'\", \"Rest\"},\n    harmonic_minor = {\"Do\", \"Ra\", \"Re\", \"Me\", \"Mi\", \"Fa\", \"Se\", \"Sol\", \"Le\", \"La\", \"Te\", \"Ti\", \"Do'\", \"Rest\"},\n    melodic_minor = {\"Do\", \"Ra\", \"Re\", \"Me\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\", \"Rest\"},\n    all = {\"Do\", \"Di\", \"Re\", \"Ri\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\", \"Rest\"}\n}\n\nlocal function getSolfegeNotesForScale(scaleMode)\n    if scaleMode == \"minor\" then\n        return SOLFEGE_NOTES_BY_SCALE.natural_minor\n    end\n    return SOLFEGE_NOTES_BY_SCALE[scaleMode] or SOLFEGE_NOTES_BY_SCALE.major\nend\n\nlocal function getStepSolfegeLyric(state, step)\n    if type(step) ~= \"table\" then\n        return nil\n    end\n\n    local noteIndex\n    if step.note ~= nil then\n        noteIndex = step.note\n    elseif step.notes and step.notes[1] and step.notes[1].note ~= nil then\n        noteIndex = step.notes[1].note\n    end\n\n    if noteIndex == nil then\n        return nil\n    end\n\n    local solfegeNotes = getSolfegeNotesForScale(state and state.solfegeScale or \"major\")\n    local solfege = solfegeNotes[noteIndex + 1]\n    if type(solfege) ~= \"string\" then\n        return nil\n    end\n\n    return solfege:gsub(\"'\", \"\")\nend\n\n\nlocal function isStepMarkerToken(token)\n    local normalized = token and token:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\") or \"\"\n    if normalized == \"\" then\n        return false\n    end\n\n    local lower = normalized:lower()\n    return\n        normalized:match(\"^%d+[%.%):%]:%-]?$\") ~= nil or\n        lower == \"step\" or\n        lower == \"steps\" or\n        lower:match(\"^steps?[%-%s_]*%d+[%.%):%]:%-]?$\") ~= nil\nend\n\nlocal function appendLyricToken(tokens, token)\n    local normalized = token:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n    if normalized == \"\" then\n        return\n    end\n\n    if normalized == \"_\" then\n        tokens[#tokens + 1] = \"_\"\n        return\n    end\n\n    -- Melisma shorthand: trailing underscores extend a lyric across extra steps.\n    -- Example: \"Glo__\" becomes {\"Glo\", \"_\", \"_\"}.\n    local base, continuation = normalized:match(\"^(.-)(_+)$\")\n    if base and base ~= \"\" and continuation then\n        tokens[#tokens + 1] = base\n        for _ = 1, #continuation do\n            tokens[#tokens + 1] = \"_\"\n        end\n        return\n    end\n\n    tokens[#tokens + 1] = normalized\nend\n\nfunction LyricsImport.tokenizeLyricsText(lyricsText)\n    if type(lyricsText) ~= \"string\" then\n        return {}\n    end\n\n    local tokens = {}\n    for token in lyricsText:gmatch(\"%S+\") do\n        if not isStepMarkerToken(token) then\n            appendLyricToken(tokens, token)\n        end\n    end\n\n    return tokens\nend\n\nlocal function xmlUnescape(text)\n    if type(text) ~= \"string\" then\n        return \"\"\n    end\n\n    return (text\n        :gsub(\"&lt;\", \"<\")\n        :gsub(\"&gt;\", \">\")\n        :gsub(\"&quot;\", '\"')\n        :gsub(\"&apos;\", \"'\")\n        :gsub(\"&amp;\", \"&\"))\nend\n\nlocal function xmlEscape(text)\n    if type(text) ~= \"string\" then\n        return \"\"\n    end\n\n    return (text\n        :gsub(\"&\", \"&amp;\")\n        :gsub(\"<\", \"&lt;\")\n        :gsub(\">\", \"&gt;\")\n        :gsub('\"', \"&quot;\")\n        :gsub(\"'\", \"&apos;\"))\nend\n\nlocal function shellEscape(arg)\n    return string.format(\"%q\", tostring(arg or \"\"))\nend\n\nlocal function extractDocumentXml(filename)\n    local cmd = string.format(\"unzip -p %s word/document.xml 2>/dev/null\", shellEscape(filename))\n    local pipe = io.popen(cmd, \"r\")\n    if not pipe then\n        return nil, \"could not run unzip\"\n    end\n\n    local xml = pipe:read(\"*a\")\n    local ok = pipe:close()\n    if not ok or not xml or xml == \"\" then\n        return nil, \"could not read word/document.xml from DOCX\"\n    end\n\n    -- Collapse newlines so Lua patterns (which don't match \\n with .) work on\n    -- multi-line XML produced by LibreOffice, Google Docs, etc.\n    xml = xml:gsub(\"\\r\\n\", \" \"):gsub(\"[\\r\\n]\", \" \")\n    return xml\nend\n\nlocal function extractParagraphText(paragraph)\n    local parts = {}\n    local cursor = 1\n\n    while true do\n        local textStart, textEnd, textValue = paragraph:find(\"<w:t[^>]*>(.-)</w:t>\", cursor)\n        local breakStart, breakEnd = paragraph:find(\"<w:br[^>]*/>\", cursor)\n        local carriageStart, carriageEnd = paragraph:find(\"<w:cr[^>]*/>\", cursor)\n\n        local nextKind = nil\n        local nextStart = nil\n        local nextEnd = nil\n        local nextValue = nil\n\n        if textStart then\n            nextKind = \"text\"\n            nextStart = textStart\n            nextEnd = textEnd\n            nextValue = textValue\n        end\n        if breakStart and (not nextStart or breakStart < nextStart) then\n            nextKind = \"break\"\n            nextStart = breakStart\n            nextEnd = breakEnd\n            nextValue = \"\\n\"\n        end\n        if carriageStart and (not nextStart or carriageStart < nextStart) then\n            nextKind = \"break\"\n            nextStart = carriageStart\n            nextEnd = carriageEnd\n            nextValue = \"\\n\"\n        end\n\n        if not nextKind then\n            break\n        end\n\n        if nextKind == \"text\" then\n            parts[#parts + 1] = xmlUnescape(nextValue)\n        else\n            parts[#parts + 1] = nextValue\n        end\n        cursor = nextEnd + 1\n    end\n\n    local value = table.concat(parts, \"\")\n    value = value:gsub(\"[ \\t\\r]+\\n\", \"\\n\")\n    value = value:gsub(\"\\n[ \\t\\r]+\", \"\\n\")\n    value = value:gsub(\"^[ \\t\\r]+\", \"\")\n    value = value:gsub(\"[ \\t\\r]+$\", \"\")\n    return value\nend\n\nlocal function extractLyricTokensFromXml(xml)\n    local paragraphs = {}\n\n    for paragraph in xml:gmatch(\"<w:p[^>]*>(.-)</w:p>\") do\n        local value = extractParagraphText(paragraph)\n        if value:gsub(\"[ \\t\\r\\n]+\", \"\") ~= \"\" then\n            paragraphs[#paragraphs + 1] = value\n        end\n    end\n\n    local tokens = {}\n    for paragraphIndex, paragraphText in ipairs(paragraphs) do\n        local lineCount = 0\n        for _ in (paragraphText .. \"\\n\"):gmatch(\"(.-)\\n\") do\n            lineCount = lineCount + 1\n        end\n\n        local lineIndex = 0\n        for line in (paragraphText .. \"\\n\"):gmatch(\"(.-)\\n\") do\n            lineIndex = lineIndex + 1\n            local lineStart = #tokens + 1\n            local lineTokens = LyricsImport.tokenizeLyricsText(line)\n            for _, token in ipairs(lineTokens) do\n                tokens[#tokens + 1] = token\n            end\n            if #tokens >= lineStart then\n                if lineIndex < lineCount then\n                    tokens[#tokens] = tokens[#tokens] .. \"\\n\"\n                elseif paragraphIndex < #paragraphs then\n                    tokens[#tokens] = tokens[#tokens] .. \"\\n\\n\"\n                end\n            end\n        end\n    end\n\n    return tokens\nend\n\nfunction LyricsImport.importDocxLyricsFile(state, filename)\n    if type(filename) ~= \"string\" or filename == \"\" then\n        return false, \"invalid filename\"\n    end\n\n    local sequence = state and state.sequence\n    local sequenceLength = state and state.sequenceLength\n    if type(sequence) ~= \"table\" or type(sequenceLength) ~= \"number\" then\n        return false, \"invalid sequence state\"\n    end\n\n    local xml, xmlErr = extractDocumentXml(filename)\n    if not xml then\n        return false, xmlErr\n    end\n\n    local tokens = extractLyricTokensFromXml(xml)\n    if #tokens == 0 then\n        return false, \"no lyrics found in DOCX\"\n    end\n\n    local importStepCount = sequenceLength\n    if importStepCount <= 0 then\n        importStepCount = #tokens\n    else\n        -- Keep importing lyric tokens even when they outnumber pitched steps.\n        importStepCount = math.max(importStepCount, #tokens)\n    end\n    importStepCount = math.min(importStepCount, MAX_IMPORT_STEPS)\n    if importStepCount <= 0 then\n        return false, \"no steps available in active sequence\"\n    end\n\n    local defaultOctave = (state and state.currentOctave) or 4\n    local defaultLength = (state and state.stepBeats) or 1\n\n    for i = 1, importStepCount do\n        local step = sequence[i]\n        if not step then\n            step = {\n                note = 13,\n                octave = defaultOctave,\n                length = defaultLength,\n            }\n            sequence[i] = step\n        end\n        step.lyric = nil\n    end\n\n    local assigned = 0\n    for stepIndex = 1, importStepCount do\n        local tokenIndex = stepIndex\n        local token = tokens[tokenIndex]\n        if not token then break end\n        sequence[stepIndex].lyric = token\n        assigned = assigned + 1\n    end\n\n    if assigned > 0 and sequenceLength < assigned then\n        state.sequenceLength = assigned\n    end\n\n    if assigned > 0 and type(state.sequenceLengths) == \"table\" then\n        local activeIndex = tonumber(state.activeSequenceIndex) or 1\n        if activeIndex >= 1 then\n            state.sequenceLengths[activeIndex] = math.max(state.sequenceLengths[activeIndex] or 0, state.sequenceLength or assigned)\n        end\n    end\n\n    return true, {\n        assigned = assigned,\n        tokens = #tokens,\n        noteSteps = importStepCount\n    }\nend\n\nfunction LyricsImport.importDocxLyricNotesFile(state, filename)\n    if type(filename) ~= \"string\" or filename == \"\" then\n        return false, \"invalid filename\"\n    end\n\n    local xml, xmlErr = extractDocumentXml(filename)\n    if not xml then\n        return false, xmlErr\n    end\n\n    local paragraphTexts = {}\n    for paragraph in xml:gmatch(\"<w:p[^>]*>(.-)</w:p>\") do\n        local value = extractParagraphText(paragraph)\n        paragraphTexts[#paragraphTexts + 1] = value\n    end\n\n    -- Remove trailing empty paragraphs\n    while #paragraphTexts > 0 and paragraphTexts[#paragraphTexts]:gsub(\"[ \\t\\r\\n]+\", \"\") == \"\" do\n        table.remove(paragraphTexts)\n    end\n\n    if #paragraphTexts == 0 then\n        return false, \"no content found in DOCX\"\n    end\n\n    local text = table.concat(paragraphTexts, \"\\n\\n\")\n    text = text:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n    if text == \"\" then\n        return false, \"no content found in DOCX\"\n    end\n\n    state.lyricNotesBuffer = text\n    state.lyricNotesCursor = nil\n    return true, { length = #text }\nend\n\nlocal function collectLyricTokens(state)\n    local sequence = state and state.sequence\n    local sequenceLength = state and state.sequenceLength\n    if type(sequence) ~= \"table\" or type(sequenceLength) ~= \"number\" then\n        return nil, \"invalid sequence state\"\n    end\n\n    local tokens = {}\n    for i = 1, sequenceLength do\n        local step = sequence[i]\n        if step then\n            local lyricValue = step.lyric\n            if state and state.showSolfegeLyrics then\n                -- In solfege mode export all pitched steps, not just those with lyrics.\n                lyricValue = getStepSolfegeLyric(state, step) or lyricValue\n            end\n            if type(lyricValue) == \"string\" then\n                -- Strip leading/trailing spaces and tabs but preserve trailing \\n\n                -- which are line/paragraph break markers used by buildDocumentXml.\n                local lyric = lyricValue:gsub(\"^[ \\t\\r]+\", \"\"):gsub(\"[ \\t\\r]+$\", \"\")\n                if lyric:gsub(\"\\n\", \"\") ~= \"\" then\n                    tokens[#tokens + 1] = lyric\n                end\n            end\n        end\n    end\n\n    -- Fallback: if no per-step lyrics, use the lyric notes text buffer\n    if #tokens == 0 then\n        local bufTokens = LyricsImport.tokenizeLyricsText(state and state.lyricNotesBuffer or \"\")\n        if #bufTokens > 0 then\n            return bufTokens\n        end\n        return nil, \"no lyrics available in active sequence\"\n    end\n\n    return tokens\nend\n\nlocal function buildDocumentXml(tokens)\n    local parts = {\n        '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>',\n        '<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">',\n        '<w:body>'\n    }\n\n    local paragraphs = {{{}}}\n    local currentParagraph = paragraphs[1]\n    local currentLine = currentParagraph[1]\n\n    for _, token in ipairs(tokens) do\n        local value = token:gsub(\"\\r\", \"\")\n        local endsParagraph = value:find(\"\\n\\n\", 1, true) ~= nil\n        local endsLine = not endsParagraph and value:find(\"\\n\", 1, true) ~= nil\n        value = value:gsub(\"\\n+\", \"\")\n        if value ~= \"\" then\n            currentLine[#currentLine + 1] = value\n        end\n\n        if endsParagraph then\n            currentParagraph = {{}}\n            currentLine = currentParagraph[1]\n            paragraphs[#paragraphs + 1] = currentParagraph\n        elseif endsLine then\n            currentLine = {}\n            currentParagraph[#currentParagraph + 1] = currentLine\n        end\n    end\n\n    local emittedParagraph = false\n    for _, paragraphLines in ipairs(paragraphs) do\n        local hasContent = false\n        for _, lineTokens in ipairs(paragraphLines) do\n            if #lineTokens > 0 then\n                hasContent = true\n                break\n            end\n        end\n        if hasContent then\n            emittedParagraph = true\n            parts[#parts + 1] = '<w:p>'\n            for lineIndex, lineTokens in ipairs(paragraphLines) do\n                if lineIndex > 1 then\n                    parts[#parts + 1] = '<w:r><w:br/></w:r>'\n                end\n                for tokenIndex, paragraphToken in ipairs(lineTokens) do\n                    local prefix = (tokenIndex == 1) and \"\" or \" \"\n                    parts[#parts + 1] = '<w:r><w:t xml:space=\"preserve\">' .. xmlEscape(prefix .. paragraphToken) .. '</w:t></w:r>'\n                end\n            end\n            parts[#parts + 1] = '</w:p>'\n        end\n    end\n\n    if not emittedParagraph then\n        parts[#parts + 1] = '<w:p></w:p>'\n    end\n\n    parts[#parts + 1] = '<w:sectPr/></w:body></w:document>'\n    return table.concat(parts)\nend\n\nlocal function writeFile(path, contents)\n    local file = io.open(path, \"w\")\n    if not file then\n        return false\n    end\n    file:write(contents)\n    file:close()\n    return true\nend\n\nfunction LyricsImport.exportDocxLyricsFile(state, filename)\n    if type(filename) ~= \"string\" or filename == \"\" then\n        return false, \"invalid filename\"\n    end\n\n    local tokens, tokenErr = collectLyricTokens(state)\n    if not tokens then\n        return false, tokenErr\n    end\n\n    local tmpDir = os.tmpname() .. \"-lyrics-docx\"\n    os.remove(tmpDir)\n    os.execute(\"mkdir -p \" .. shellEscape(tmpDir .. \"/_rels\"))\n    os.execute(\"mkdir -p \" .. shellEscape(tmpDir .. \"/word/_rels\"))\n\n    local filesOk = true\n    filesOk = filesOk and writeFile(tmpDir .. \"/[Content_Types].xml\", [[<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n  <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n  <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n  <Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/>\n</Types>\n]])\n    filesOk = filesOk and writeFile(tmpDir .. \"/_rels/.rels\", [[<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n  <Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>\n</Relationships>\n]])\n    filesOk = filesOk and writeFile(tmpDir .. \"/word/document.xml\", buildDocumentXml(tokens))\n    filesOk = filesOk and writeFile(tmpDir .. \"/word/_rels/document.xml.rels\", [[<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>\n]])\n\n    if not filesOk then\n        os.execute(\"rm -rf \" .. shellEscape(tmpDir))\n        return false, \"could not write DOCX package files\"\n    end\n\n    local cmd = string.format(\n        \"cd %s && zip -qr %s [Content_Types].xml _rels word >/dev/null 2>&1\",\n        shellEscape(tmpDir),\n        shellEscape(filename)\n    )\n    local zipStatus = os.execute(cmd)\n    os.execute(\"rm -rf \" .. shellEscape(tmpDir))\n    if not zipStatus then\n        return false, \"could not create DOCX archive\"\n    end\n\n    return true, { exported = #tokens }\nend\n\nlocal function buildLyricCreatorText(tokens)\n    local parts = {}\n    local lineTokens = {}\n\n    local function flushLine()\n        if #lineTokens > 0 then\n            parts[#parts + 1] = table.concat(lineTokens, \" \")\n            lineTokens = {}\n        end\n    end\n\n    for _, token in ipairs(tokens) do\n        local value = token:gsub(\"\\r\", \"\")\n        local endsParagraph = value:find(\"\\n\\n\", 1, true) ~= nil\n        local endsLine = (not endsParagraph) and value:find(\"\\n\", 1, true) ~= nil\n        value = value:gsub(\"\\n+\", \"\")\n\n        if value ~= \"\" then\n            lineTokens[#lineTokens + 1] = value\n        end\n\n        if endsParagraph or endsLine then\n            flushLine()\n            if endsParagraph then\n                parts[#parts + 1] = \"\"\n            end\n        end\n    end\n\n    flushLine()\n    return table.concat(parts, \"\\n\") .. \"\\n\"\nend\n\nfunction LyricsImport.exportPlainTextLyricsFile(state, filename)\n    if type(filename) ~= \"string\" or filename == \"\" then\n        return false, \"invalid filename\"\n    end\n\n    local tokens, tokenErr = collectLyricTokens(state)\n    if not tokens then\n        return false, tokenErr\n    end\n\n    local text = buildLyricCreatorText(tokens)\n    if not writeFile(filename, text) then\n        return false, \"could not write text file\"\n    end\n\n    return true, { exported = #tokens }\nend\n\nfunction LyricsImport.exportDocxLyricNotesFile(state, filename)\n    if type(filename) ~= \"string\" or filename == \"\" then\n        return false, \"invalid filename\"\n    end\n\n    local raw = state and state.lyricNotesBuffer or \"\"\n    if raw:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\") == \"\" then\n        return false, \"lyric notes buffer is empty\"\n    end\n\n    -- Build tokens directly from the raw buffer text (preserving line breaks)\n    local tokens = {}\n    local lines = {}\n    for line in (raw .. \"\\n\"):gmatch(\"(.-)\\n\") do\n        lines[#lines + 1] = line\n    end\n    for lineIdx, line in ipairs(lines) do\n        local lineTokens = {}\n        for word in line:gmatch(\"%S+\") do\n            lineTokens[#lineTokens + 1] = word\n        end\n        for i, word in ipairs(lineTokens) do\n            if i == #lineTokens then\n                if lineIdx < #lines then\n                    word = word .. \"\\n\"\n                end\n            end\n            tokens[#tokens + 1] = word\n        end\n    end\n\n    if #tokens == 0 then\n        return false, \"no content in lyric notes buffer\"\n    end\n\n    local tmpDir = os.tmpname() .. \"-lnotes-docx\"\n    os.remove(tmpDir)\n    os.execute(\"mkdir -p \" .. shellEscape(tmpDir .. \"/_rels\"))\n    os.execute(\"mkdir -p \" .. shellEscape(tmpDir .. \"/word/_rels\"))\n\n    local filesOk = true\n    filesOk = filesOk and writeFile(tmpDir .. \"/[Content_Types].xml\", [[<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n  <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n  <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n  <Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/>\n</Types>\n]])\n    filesOk = filesOk and writeFile(tmpDir .. \"/_rels/.rels\", [[<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n  <Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>\n</Relationships>\n]])\n    filesOk = filesOk and writeFile(tmpDir .. \"/word/document.xml\", buildDocumentXml(tokens))\n    filesOk = filesOk and writeFile(tmpDir .. \"/word/_rels/document.xml.rels\", [[<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>\n]])\n\n    if not filesOk then\n        os.execute(\"rm -rf \" .. shellEscape(tmpDir))\n        return false, \"could not write DOCX package files\"\n    end\n\n    local cmd = string.format(\n        \"cd %s && zip -qr %s [Content_Types].xml _rels word >/dev/null 2>&1\",\n        shellEscape(tmpDir),\n        shellEscape(filename)\n    )\n    local zipStatus = os.execute(cmd)\n    os.execute(\"rm -rf \" .. shellEscape(tmpDir))\n    if not zipStatus then\n        return false, \"could not create DOCX archive\"\n    end\n\n    return true, { exported = #tokens }\nend\n\nfunction LyricsImport.exportLyricCreatorFile(state, filename)\n    if type(filename) ~= \"string\" or filename == \"\" then\n        return false, \"invalid filename\"\n    end\n\n    local tokens, tokenErr = collectLyricTokens(state)\n    if not tokens then\n        return false, tokenErr\n    end\n\n    local text = buildLyricCreatorText(tokens)\n    if not writeFile(filename, text) then\n        return false, \"could not write Lyric Creator file\"\n    end\n\n    return true, { exported = #tokens }\nend\n\nreturn LyricsImport\n","lyrics_spellcheck.lua":"local LyricsSpellcheck = {}\n\nlocal function shellQuote(value)\n    local text = tostring(value or \"\")\n    return \"'\" .. text:gsub(\"'\", [['\"'\"']]) .. \"'\"\nend\n\nlocal function runCommandReadAll(cmd)\n    local pipe = io.popen(cmd, \"r\")\n    if not pipe then\n        return nil\n    end\n\n    local output = pipe:read(\"*a\") or \"\"\n    pipe:close()\n    return output\nend\n\nlocal function commandExists(commandName, commandRunner)\n    local output = commandRunner(\"command -v \" .. commandName .. \" 2>/dev/null\")\n    return type(output) == \"string\" and output:gsub(\"%s+\", \"\") ~= \"\"\nend\n\nlocal function collectLyricWords(sequence)\n    local words = {}\n    local seen = {}\n\n    for _, step in ipairs(sequence or {}) do\n        local lyric = step and step.lyric\n        if type(lyric) == \"string\" and lyric ~= \"\" then\n            for word in lyric:gmatch(\"[%a][%a'%-%u%l]*\") do\n                local normalized = string.lower(word)\n                if normalized ~= \"\" and not seen[normalized] then\n                    seen[normalized] = true\n                    words[#words + 1] = normalized\n                end\n            end\n        end\n    end\n\n    table.sort(words)\n    return words\nend\n\nlocal function selectChecker(commandRunner)\n    if commandExists(\"aspell\", commandRunner) then\n        return {\n            name = \"aspell\",\n            buildCommand = function(filePath)\n                return \"aspell --encoding=utf-8 list < \" .. shellQuote(filePath)\n            end,\n        }\n    end\n\n    if commandExists(\"hunspell\", commandRunner) then\n        return {\n            name = \"hunspell\",\n            buildCommand = function(filePath)\n                return \"hunspell -d en_US -l < \" .. shellQuote(filePath)\n            end,\n        }\n    end\n\n    return nil\nend\n\nfunction LyricsSpellcheck.checkSequenceLyrics(sequence, options)\n    options = options or {}\n    local commandRunner = options.commandRunner or runCommandReadAll\n    local checker = options.checker or selectChecker(commandRunner)\n\n    if not checker then\n        return {\n            ok = false,\n            error = \"No spell checker found. Install aspell or hunspell.\",\n            checkedWordCount = 0,\n            misspelledWords = {},\n        }\n    end\n\n    local words = collectLyricWords(sequence)\n    if #words == 0 then\n        return {\n            ok = true,\n            checker = checker.name,\n            checkedWordCount = 0,\n            misspelledWords = {},\n        }\n    end\n\n    local tmpPath = os.tmpname() .. \"-lyrics.txt\"\n    local file = io.open(tmpPath, \"w\")\n    if not file then\n        return {\n            ok = false,\n            error = \"Unable to create temporary file for spell check.\",\n            checkedWordCount = 0,\n            misspelledWords = {},\n        }\n    end\n\n    file:write(table.concat(words, \"\\n\"))\n    file:write(\"\\n\")\n    file:close()\n\n    local output = commandRunner(checker.buildCommand(tmpPath))\n    os.remove(tmpPath)\n\n    if type(output) ~= \"string\" then\n        return {\n            ok = false,\n            error = \"Spell check command failed to run.\",\n            checker = checker.name,\n            checkedWordCount = #words,\n            misspelledWords = {},\n        }\n    end\n\n    local misspelled = {}\n    local seen = {}\n    for line in output:gmatch(\"[^\\r\\n]+\") do\n        local token = string.lower((line:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")))\n        if token ~= \"\" and not seen[token] then\n            seen[token] = true\n            misspelled[#misspelled + 1] = token\n        end\n    end\n    table.sort(misspelled)\n\n    return {\n        ok = true,\n        checker = checker.name,\n        checkedWordCount = #words,\n        misspelledWords = misspelled,\n    }\nend\n\nreturn LyricsSpellcheck\n","main.lua":"if type(rawget(_G, \"import\")) ~= \"function\" then\n    ---@diagnostic disable-next-line: lowercase-global\n    function import(module)\n        return require(module)\n    end\nend\n\nlocal isPlaydate = rawget(_G, \"playdate\") ~= nil\nif isPlaydate then\n    import \"CoreLibs/graphics\"\n    import \"CoreLibs/sprites\"\nend\nlocal core = import \"sequencer_core\"\nlocal storage = import \"storage\"\nlocal ui = import \"ui\"\nlocal templateLibrary = import \"template_library\"\nlocal MidiImport = import \"midi_import\"\nlocal MusicXML = import \"musicxml\"\nlocal LyricsImport = import \"lyrics_import\"\nlocal LyricsSpellcheck = import \"lyrics_spellcheck\"\nlocal pm = import \"project_manager\"\nlocal cmdChat = import \"cmd_chat\"\nmessageBridge = import \"message_bridge\"\nAudioExport = import \"audio_export\"\n\n-- Set template library reference in core to avoid circular dependency\ncore.templateLibrary = templateLibrary\ncore.earTraining = import \"ear_training\"\n\n-- Platform configuration\nlocal PlatformConfig = import \"platform_config\"\nlocal platformAdapters = PlatformConfig.getAdapters()\n\nif platformAdapters.name == \"web\" and ui.setTouchMode then\n    ui.setTouchMode(true)\nend\n\nif platformAdapters.name == \"web\" and ui.setSafeArea then\n    local wh = rawget(_G, \"WebHost\")\n    if wh and wh.getSafeAreaTop and wh.getSafeAreaBottom then\n        ui.setSafeArea(wh.getSafeAreaTop(), wh.getSafeAreaBottom())\n    end\nend\n\n-- Input abstraction layer\nlocal InputAdapter = import \"input_adapter\"\nlocal inputAdapter = nil\n\n-- Graphics abstraction layer\nlocal GraphicsAdapter = import \"graphics_adapter\"\nlocal gfx = nil  -- Will be initialized as graphics adapter\n\n-- Audio abstraction layer\nlocal AudioAdapter = import \"audio_adapter\"\nlocal snd = AudioAdapter.new(platformAdapters.Audio.new())\n\n-- Storage abstraction layer\nlocal StorageAdapter = import \"storage_adapter\"\nlocal storageAdapter = StorageAdapter.new(platformAdapters.Storage.new())\nlocal _storageRoot = storageAdapter.getStorageRoot and storageAdapter.getStorageRoot()\nlocal MAX_BACKUPS = 10\n\n-- Timer abstraction layer\nlocal TimerAdapter = import \"timer_adapter\"\nlocal timerAdapter = TimerAdapter.new(platformAdapters.Timer.new())\n\n-- System abstraction layer\nlocal SystemAdapter = import \"system_adapter\"\nlocal systemAdapter = SystemAdapter.new(platformAdapters.System.new())\n\nlocal state = core.createState()\nlocal solfegeNotes = core.solfegeNotes\n\nlocal function refreshSolfegeNotes()\n    solfegeNotes = core.getSolfegeNotes(state.solfegeScale)\n    state._acDirty = true\nend\n\nrefreshSolfegeNotes()\n\nlocal syncPatternSelection\nlocal schedulePlaybackStopTimer\nlocal stopAllVoices\nlocal setAcapellaMode\nlocal stopPitchRecognition\nlocal clearPitchRecognitionResults\nlocal applySingSolfegeOctaveOffset\nlocal resetSingSolfegeOctaveOffset\nlocal playSingSolfegeRoot\nlocal getPlaybackSequence\nlocal transposeNoteForKey\n\nlocal _isSteamDeckCached = nil\nlocal function isRunningOnSteamDeck()\n    if _isSteamDeckCached ~= nil then\n        return _isSteamDeckCached\n    end\n\n    if platformAdapters.name ~= \"sdl\" then\n        _isSteamDeckCached = false\n        return false\n    end\n\n    -- SteamOS sets SteamDeck=1 in the environment\n    if os and os.getenv then\n        local sd = os.getenv(\"SteamDeck\") or os.getenv(\"STEAM_DECK\")\n        if sd == \"1\" then\n            _isSteamDeckCached = true\n            return true\n        end\n    end\n\n    -- Fallback: check /etc/os-release for SteamOS\n    local f = io.open(\"/etc/os-release\", \"r\")\n    if f then\n        local content = f:read(\"*a\") or \"\"\n        f:close()\n        if content:lower():find(\"steamos\", 1, true) then\n            _isSteamDeckCached = true\n            return true\n        end\n    end\n\n    _isSteamDeckCached = false\n    return false\nend\n\nlocal _isMacDesktopCached = nil\nlocal function isRunningOnMacDesktop()\n    if _isMacDesktopCached ~= nil then\n        return _isMacDesktopCached\n    end\n\n    -- Web platform uses the same desktop-style chrome UI\n    if platformAdapters.name == \"web\" then\n        _isMacDesktopCached = true\n        return true\n    end\n\n    if platformAdapters.name ~= \"sdl\" then\n        _isMacDesktopCached = false\n        return false\n    end\n\n    -- Check OSTYPE env var first, then fall back to uname for macOS detection\n    -- (OSTYPE is a shell variable, not always exported to env)\n    local ostype = \"\"\n    if os and os.getenv then\n        ostype = string.lower(tostring(os.getenv(\"OSTYPE\") or \"\"))\n    end\n    if ostype:find(\"darwin\", 1, true) then\n        _isMacDesktopCached = true\n        return true\n    end\n\n    local handle = io.popen(\"uname\")\n    if handle then\n        local result = handle:read(\"*a\") or \"\"\n        handle:close()\n        if string.lower(result):find(\"darwin\", 1, true) then\n            _isMacDesktopCached = true\n            return true\n        end\n    end\n\n    _isMacDesktopCached = false\n    return false\nend\n\nfunction showSequenceSelectScreen()\n    state.showingSequenceSelect = true\n    state.selectedSequenceOption = 1\nend\n\nfunction closeSequenceSelectScreen()\n    state.showingSequenceSelect = false\nend\n\nfunction showStepSelectScreen(seqIndex)\n    state.showingStepSelect = true\n    state.stepSelectSequenceIndex = seqIndex\n    state.selectedStepOption = 1\nend\n\nfunction closeStepSelectScreen()\n    state.showingStepSelect = false\n    state.stepSelectSequenceIndex = nil\nend\n\nlocal function deepCopyStep(step)\n    if not step then return nil end\n    local copy = {}\n    for k, v in pairs(step) do\n        if type(v) == \"table\" then\n            copy[k] = {}\n            for k2, v2 in pairs(v) do\n                if type(v2) == \"table\" then\n                    local inner = {}\n                    for k3, v3 in pairs(v2) do inner[k3] = v3 end\n                    copy[k][k2] = inner\n                else\n                    copy[k][k2] = v2\n                end\n            end\n        else\n            copy[k] = v\n        end\n    end\n    return copy\nend\n\nlocal function tryDeleteCurrentStep()\n    if state.showingStepSelect or state.showingSequenceSelect\n       or state.showingModeSelect or state.showingTemplateBrowser\n       or state.showingGamepadPicker\n       or state.singSolfegeMode or showRecordingScreen or showWelcomeScreen\n       or state.earTrainingMode then\n        return false\n    end\n\n    local si = state.currentStep\n    local seqIndex = state.activeSequenceIndex\n    -- Allow deleting empty steps too (si must be within sequenceLength)\n    if not si or si < 1 or si > (state.sequenceLength or 0) then\n        return false\n    end\n\n    core.deleteStep(state, seqIndex, si)\n\n    -- Keep playback cursor pointing at the same note after deletion shifts indices\n    if state.isPlaying and state.currentPlaybackStep > si then\n        state.currentPlaybackStep = math.max(1, state.currentPlaybackStep - 1)\n        state.playbackPosition = state.currentPlaybackStep\n    end\n\n    markSequenceDirty()\n    local newLen = state.sequenceLength or 0\n    if newLen < 1 then\n        state.currentStep = 1\n    elseif si > newLen then\n        state.currentStep = newLen\n    end\n    return true\nend\n\nfunction removeEmptySteps()\n    local seqIndex = state.activeSequenceIndex\n    local i = 1\n    while i <= (state.sequenceLength or 0) do\n        local step = state.sequence[i]\n        local isEmpty = not step or (not core.isChord(step) and step.note == nil)\n        if isEmpty then\n            core.deleteStep(state, seqIndex, i)\n        else\n            i = i + 1\n        end\n    end\n    state.currentStep = math.min(math.max(1, state.currentStep), math.max(state.sequenceLength or 0, 1))\n    markSequenceDirty()\nend\n\nlocal function canRecordMicStepInput()\n    return not state.isPlaying\n       and not state.showingModeSelect\n       and not state.showingTemplateBrowser\n       and not state.showingSequenceSelect\n       and not state.showingStepSelect\n       and not state.showingMidiInPicker\n       and not state.showingMicInputPicker\n       and not state.showingGamepadPicker\n       and not state.showingMidiControls\n       and state.editMode ~= \"chord\"\nend\n\nlocal function getEnabledStepLoopRange(sequenceIndex, sequenceLength)\n    if not state.loopPlayback then\n        return nil, nil\n    end\n\n    if sequenceIndex ~= (state.activeSequenceIndex or 1) then\n        return nil, nil\n    end\n\n    return core.getValidStepLoopRange(state, sequenceLength)\nend\n\n\nlocal _stepHistory = {\n    maxEntries        = 100,\n    undoStack         = {},\n    redoStack         = {},\n    suppressTracking  = false,\n    lastSnapshot      = nil,\n    capture           = nil,\n}\n\nlocal function resetStepHistory()\n    _stepHistory.undoStack        = {}\n    _stepHistory.redoStack        = {}\n    _stepHistory.suppressTracking = false\n    _stepHistory.lastSnapshot     = _stepHistory.capture()\nend\n\n_stepHistory.capture = function()\n    local snapshot = {\n        sequences = {},\n        sequenceLengths = {},\n        activeSequenceIndex = state.activeSequenceIndex,\n        currentStep = state.currentStep,\n    }\n\n    for seqIndex = 1, core.maxSequences do\n        local length = state.sequenceLengths[seqIndex] or 0\n        snapshot.sequenceLengths[seqIndex] = length\n        snapshot.sequences[seqIndex] = core.copySequenceData(state.sequences[seqIndex], length)\n    end\n\n    return snapshot\nend\n\nlocal function areStepHistorySnapshotsEqual(left, right)\n    if not left or not right then\n        return false\n    end\n\n    for seqIndex = 1, core.maxSequences do\n        local leftLength = left.sequenceLengths[seqIndex] or 0\n        local rightLength = right.sequenceLengths[seqIndex] or 0\n        if leftLength ~= rightLength then\n            return false\n        end\n\n        local leftSequence = left.sequences[seqIndex] or {}\n        local rightSequence = right.sequences[seqIndex] or {}\n        for stepIndex = 1, math.max(leftLength, rightLength) do\n            local leftStep = leftSequence[stepIndex]\n            local rightStep = rightSequence[stepIndex]\n            if (leftStep ~= nil) ~= (rightStep ~= nil) then\n                return false\n            end\n            if leftStep and rightStep then\n                if leftStep.note ~= rightStep.note\n                    or leftStep.octave ~= rightStep.octave\n                    or leftStep.lyric ~= rightStep.lyric\n                    or leftStep.length ~= rightStep.length then\n                    return false\n                end\n\n                local leftNotes = leftStep.notes\n                local rightNotes = rightStep.notes\n                if (leftNotes ~= nil) ~= (rightNotes ~= nil) then\n                    return false\n                end\n                if leftNotes and rightNotes then\n                    if #leftNotes ~= #rightNotes then\n                        return false\n                    end\n                    for noteIndex = 1, #leftNotes do\n                        local ln = leftNotes[noteIndex]\n                        local rn = rightNotes[noteIndex]\n                        if not rn or ln.note ~= rn.note or ln.octave ~= rn.octave then\n                            return false\n                        end\n                    end\n                end\n            end\n        end\n    end\n\n    return true\nend\n\nlocal function recordStepHistoryIfNeeded()\n    if _stepHistory.suppressTracking then\n        return\n    end\n\n    local currentSnapshot = _stepHistory.capture()\n    if not _stepHistory.lastSnapshot then\n        _stepHistory.lastSnapshot = currentSnapshot\n        return\n    end\n\n    if areStepHistorySnapshotsEqual(currentSnapshot, _stepHistory.lastSnapshot) then\n        return\n    end\n\n    table.insert(_stepHistory.undoStack, _stepHistory.lastSnapshot)\n    if #_stepHistory.undoStack > _stepHistory.maxEntries then\n        table.remove(_stepHistory.undoStack, 1)\n    end\n    _stepHistory.redoStack    = {}\n    _stepHistory.lastSnapshot = currentSnapshot\nend\n\nlocal function applyStepHistorySnapshot(snapshot)\n    if not snapshot then\n        return false\n    end\n\n    for seqIndex = 1, core.maxSequences do\n        local length = snapshot.sequenceLengths[seqIndex] or 0\n        state.sequenceLengths[seqIndex] = length\n        state.sequences[seqIndex] = core.copySequenceData(snapshot.sequences[seqIndex], length)\n    end\n\n    state.activeSequenceIndex = snapshot.activeSequenceIndex or state.activeSequenceIndex\n    state.sequence = state.sequences[state.activeSequenceIndex]\n    state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\n\n    local maxStep = math.max(1, state.sequenceLength)\n    state.currentStep = math.min(math.max(snapshot.currentStep or 1, 1), maxStep)\n    if state.isPlaying then\n        state.currentPlaybackStep = math.min(math.max(state.currentPlaybackStep or 1, 1), maxStep)\n        state.playbackPosition = state.currentPlaybackStep\n    end\n    return true\nend\n\nlocal function undoStepChange()\n    recordStepHistoryIfNeeded()\n    if #_stepHistory.undoStack < 1 then\n        return false\n    end\n\n    local targetSnapshot = table.remove(_stepHistory.undoStack)\n    local currentSnapshot = _stepHistory.capture()\n    table.insert(_stepHistory.redoStack, currentSnapshot)\n    _stepHistory.suppressTracking = true\n    local applied = applyStepHistorySnapshot(targetSnapshot)\n    _stepHistory.suppressTracking = false\n    if applied then\n        _stepHistory.lastSnapshot = _stepHistory.capture()\n        markSequenceDirty()\n    end\n    return applied\nend\n\nlocal function redoStepChange()\n    if #_stepHistory.redoStack < 1 then\n        return false\n    end\n\n    local targetSnapshot = table.remove(_stepHistory.redoStack)\n    local currentSnapshot = _stepHistory.capture()\n    table.insert(_stepHistory.undoStack, currentSnapshot)\n    _stepHistory.suppressTracking = true\n    local applied = applyStepHistorySnapshot(targetSnapshot)\n    _stepHistory.suppressTracking = false\n    if applied then\n        _stepHistory.lastSnapshot = _stepHistory.capture()\n        markSequenceDirty()\n    end\n    return applied\nend\n\n-- MIDI input device picker screen functions\nlocal function normalizeMidiDeviceName(name)\n    local normalized = tostring(name or \"\"):lower()\n    normalized = normalized:gsub(\"%s+\", \" \"):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n    -- CoreMIDI/ALSA names can append dynamic endpoint decorations that change\n    -- between boots (e.g. \"CASIO USB-MIDI Port 1\", \"CASIO USB-MIDI 0\").\n    normalized = normalized:gsub(\"%s+port%s+%d+$\", \"\")\n    normalized = normalized:gsub(\"%s*[%-%_]in%s*$\", \"\")\n    normalized = normalized:gsub(\"%s+%d+$\", \"\")\n    return normalized\nend\n\nlocal function findMidiInputDeviceByName(devices, savedName)\n    if savedName == \"\" then return nil end\n\n    -- 1) Exact match (current behavior)\n    local i = 1\n    while i <= #devices do\n        if devices[i].name == savedName then return devices[i] end\n        i = i + 1\n    end\n\n    -- 2) Normalized match for device families with unstable suffixes.\n    local target = normalizeMidiDeviceName(savedName)\n    if target == \"\" then return nil end\n    i = 1\n    while i <= #devices do\n        if normalizeMidiDeviceName(devices[i].name) == target then\n            return devices[i]\n        end\n        i = i + 1\n    end\n\n    return nil\nend\n\nfunction showMidiInDevicePicker()\n    -- Build device list: first entry is \"Off\" (disconnect), then hardware sources\n    local devices = { { index = 0, name = \"Off\", isOff = true } }\n    if midiOut and midiOut.getInputDevices then\n        local hwDevices = midiOut.getInputDevices()\n        for _, dev in ipairs(hwDevices) do\n            table.insert(devices, dev)\n        end\n    end\n    state.midiInPickerDevices = devices\n    -- Pre-select currently active device\n    state.midiInPickerSelection = 1\n    local matchedDevice = findMidiInputDeviceByName(devices, state.midiInDeviceName)\n    if matchedDevice then\n        for i, dev in ipairs(devices) do\n            if dev.index == matchedDevice.index and dev.name == matchedDevice.name then\n                state.midiInPickerSelection = i\n                break\n            end\n        end\n    end\n    state.showingMidiInPicker = true\nend\n\nfunction closeMidiInDevicePicker()\n    state.showingMidiInPicker = false\nend\n\nfunction connectMidiInDevice(deviceEntry)\n    if not midiOut then\n        print(\"[MIDI] connectMidiInDevice: midiOut is nil, cannot connect\")\n        return\n    end\n    if deviceEntry.isOff then\n        if midiOut.disconnectInputDevice then midiOut.disconnectInputDevice() end\n        state.midiInDeviceName = \"\"\n        print(\"[MIDI] Disconnected MIDI input device\")\n    else\n        local ok, err\n        if midiOut.connectInputDevice then\n            ok, err = midiOut.connectInputDevice(deviceEntry.index)\n        end\n        if ok then\n            state.midiInDeviceName = deviceEntry.name\n            print(\"[MIDI] Connected to: \" .. deviceEntry.name .. \" (index \" .. deviceEntry.index .. \")\")\n        else\n            print(\"[MIDI] Failed to connect to: \" .. tostring(deviceEntry.name) ..\n                  \" (index \" .. tostring(deviceEntry.index) .. \"): \" .. tostring(err))\n            -- Still record the device name so auto-reconnect can retry on next launch\n            state.midiInDeviceName = deviceEntry.name\n        end\n    end\n    savePreferences()\nend\n\n-- Microphone input device picker screen functions\nfunction showMicInputDevicePicker()\n    local devices = { { index = 0, name = \"Default\" } }\n    if snd and snd.micinput and snd.micinput.listInputDevices then\n        local hwDevices = snd.micinput.listInputDevices() or {}\n        for _, dev in ipairs(hwDevices) do\n            table.insert(devices, dev)\n        end\n    end\n    state.micInputPickerDevices = devices\n    state.micInputPickerSelection = 1\n    for i, dev in ipairs(devices) do\n        if dev.name == state.micInputDeviceName then\n            state.micInputPickerSelection = i\n            break\n        end\n    end\n    state.showingMicInputPicker = true\nend\n\nfunction closeMicInputDevicePicker()\n    state.showingMicInputPicker = false\nend\n\nfunction connectMicInputDevice(deviceEntry)\n    if not snd or not snd.micinput or not snd.micinput.setInputDevice then\n        return\n    end\n\n    local ok = snd.micinput.setInputDevice(deviceEntry.index)\n    if ok then\n        if deviceEntry.index == 0 then\n            state.micInputDeviceName = \"\"\n        else\n            state.micInputDeviceName = deviceEntry.name or \"\"\n        end\n        savePreferences()\n        syncPitchRecognitionForSingSolfege()\n    end\nend\n\n-- Gamepad device picker screen functions\nfunction showGamepadDevicePicker()\n    local devices = { { index = -1, name = \"Off\" } }\n    local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if host and host.listGamepads then\n        local gamepads = host.listGamepads() or {}\n        for _, dev in ipairs(gamepads) do\n            table.insert(devices, dev)\n        end\n    end\n    state.gamepadPickerDevices = devices\n    state.gamepadPickerSelection = 1\n    local found = false\n    for i, dev in ipairs(devices) do\n        if dev.name == state.gamepadDeviceName then\n            state.gamepadPickerSelection = i\n            found = true\n            break\n        end\n    end\n    -- If no saved name matches but gamepad is active, pre-select the first\n    -- available device so pressing A doesn't accidentally choose \"Off\".\n    if not found and state.gamepadEnabled and #devices > 1 then\n        state.gamepadPickerSelection = 2\n    end\n    state.showingGamepadPicker = true\nend\n\nfunction closeGamepadDevicePicker()\n    state.showingGamepadPicker = false\nend\n\nfunction connectGamepadDevice(deviceEntry)\n    local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if deviceEntry.index < 0 then\n        state.gamepadEnabled = false\n        state.gamepadDeviceName = \"\"\n        if host and host.setGamepadEnabled then host.setGamepadEnabled(false) end\n        if host and host.setSelectedGamepad then host.setSelectedGamepad(0) end\n    else\n        state.gamepadEnabled = true\n        state.gamepadDeviceName = deviceEntry.name or \"\"\n        if host and host.setGamepadEnabled then host.setGamepadEnabled(true) end\n        if host and host.setSelectedGamepad then host.setSelectedGamepad(deviceEntry.index + 1) end\n    end\n    savePreferences()\nend\n\n-- MIDI Controls mapping screen functions\nfunction showMidiControlsScreen()\n    state.showingMidiControls = true\n    state.midiControlsSelection = 1\n    state.midiLearnMode = false\n    state.midiLearnTarget = nil\nend\n\nfunction closeMidiControlsScreen()\n    state.showingMidiControls = false\n    state.midiLearnMode = false\n    state.midiLearnTarget = nil\nend\n\n-- Mode selection screen functions\nfunction showModeSelectScreen()\n    state.showingModeSelect = true\n    state.selectedModeOption = state.singSolfegeMode and 1 or 2\nend\n\nfunction closeModeSelectScreen()\n    state.showingModeSelect = false\nend\n\nfunction getModeList()\n    return {\n        { label = \"Sing Solfege\", description = \"Practice singing with pitch detection\" },\n        { label = \"Compose\", description = \"Compose patterns\" },\n        { label = \"Patterns\", description = \"Manage and switch patterns\" },\n        { label = \"Import MIDI\", description = \"Open a MIDI file\" },\n        { label = \"Steps\", description = \"Edit steps for the active pattern\" },\n        { label = \"MIDI Out\", description = \"MIDI output: \" .. (state.midiOutEnabled and \"ON\" or \"OFF\") },\n        { label = \"MIDI Controls\", description = \"Map MIDI buttons/pedals to app controls\" }\n    }\nend\n\nlocal function refreshSystemMenu()\n    systemAdapter:setupMenu({\n        {\n            label = \"Steps\",\n            action = function()\n                if state.showingModeSelect then\n                    closeModeSelectScreen()\n                end\n                showStepSelectScreen()\n            end\n        },\n        {\n            label = \"Shape Notes: \" .. (state.useShapeNotes and \"ON\" or \"OFF\"),\n            action = function()\n                state.useShapeNotes = not state.useShapeNotes\n                savePreferences()\n                refreshSystemMenu()\n            end\n        },\n    })\nend\n\nlocal function toggleDisplayOption(optionKey)\n    if optionKey == \"darkMode\" then\n        state.darkMode = not state.darkMode\n        if gfx then gfx.setDarkMode(state.darkMode) end\n    elseif optionKey == \"composeMode\" then\n        if enterComposeMode then\n            enterComposeMode({commitInput = true, refreshBuffer = true})\n        end\n    elseif optionKey == \"singMode\" then\n        if enterSingMode then\n            enterSingMode({commitInput = true, refreshBuffer = true})\n        end\n    elseif optionKey == \"lyricsOnlyMode\" then\n        if enterLyricsScreen then\n            enterLyricsScreen({commitInput = true, refreshBuffer = true})\n        end\n    elseif optionKey == \"stepsOnlyMode\" then\n        if enterStepsOnlyScreen then\n            enterStepsOnlyScreen({commitInput = true, refreshBuffer = true})\n        end\n    elseif optionKey == \"stepsLyricsMode\" then\n        if enterStepsLyricsScreen then\n            enterStepsLyricsScreen({commitInput = true, refreshBuffer = true})\n        end\n    elseif optionKey == \"showToolsRow\" then\n        state.showToolsRow = not state.showToolsRow\n    elseif optionKey == \"showPlaybackRow\" then\n        state.showPlaybackRow = not state.showPlaybackRow\n    elseif optionKey == \"showMicRow\" then\n        state.showMicRow = not state.showMicRow\n    elseif optionKey == \"showAddStepButton\" then\n        state.showAddStepButton = not state.showAddStepButton\n    elseif optionKey == \"hideSteps\" then\n        state.hideSteps = not state.hideSteps\n    elseif optionKey == \"showBarsBeatsRow\" then\n        state.showBarsBeatsRow = not state.showBarsBeatsRow\n        if not state.showBarsBeatsRow then\n            state.barEditing = false\n            state.beatEditing = false\n            state.seekEditing = false\n            state.bpmEditing = false\n        end\n    elseif optionKey == \"showBarLines\" then\n        state.showBarLines = not state.showBarLines\n    elseif optionKey == \"showNoteNames\" then\n        state.showNoteNames = not state.showNoteNames\n    elseif optionKey == \"showNoteLengths\" then\n        state.showNoteLengths = not state.showNoteLengths\n    elseif optionKey == \"showOctaveNumbers\" then\n        state.showOctaveNumbers = not (state.showOctaveNumbers ~= false)\n    elseif optionKey == \"showRomanNumerals\" then\n        state.showRomanNumerals = not state.showRomanNumerals\n    elseif optionKey == \"useShapeNotes\" then\n        state.useShapeNotes = not state.useShapeNotes\n    elseif optionKey == \"showLyrics\" then\n        state.showLyrics = not state.showLyrics\n        if not state.showLyrics then\n            state.lyricEditingStepIndex = nil\n            state.lyricInputBuffer = nil\n        end\n    elseif optionKey == \"showSolfegeLyrics\" then\n        state.showSolfegeLyrics = not state.showSolfegeLyrics\n    elseif optionKey == \"lyricNotesPanelOpen\" then\n        state.lyricNotesPanelOpen = not state.lyricNotesPanelOpen\n        if state.lyricNotesPanelOpen then\n            state.lyricNotesBuffer = state.lyricNotesBuffer or \"\"\n            state.lyricEditingStepIndex = nil\n            state.lyricInputBuffer = nil\n        end\n    elseif optionKey == \"sidebarOpen\" then\n        state.sidebarOpen = not state.sidebarOpen\n    elseif optionKey == \"showSolfegeTextInput\" then\n        state.showSolfegeTextInput = not state.showSolfegeTextInput\n        if not state.showSolfegeTextInput then\n            state.solfegeInputActive = false\n        end\n    elseif optionKey == \"showSolfegeButtons\" then\n        state.showSolfegeButtons = not state.showSolfegeButtons\n    end\n    savePreferences()\n    refreshSystemMenu()\nend\n\nlocal function getLyricBreakSuffix(lyric)\n    if type(lyric) ~= \"string\" then return \"\" end\n    if lyric:find(\"\\n\\n\", 1, true) then return \"\\n\\n\" end\n    if lyric:sub(-1) == \"\\n\" then return \"\\n\" end\n    return \"\"\nend\n\nlocal function commitLyricBufferToStep(stepIndex)\n    if not stepIndex then return false end\n\n    local raw = state.lyricInputBuffer or \"\"\n\n    -- Split on hyphens, filter out empty parts\n    local parts = {}\n    for part in raw:gmatch(\"[^-]+\") do\n        local trimmed = part:match(\"^%s*(.-)%s*$\")\n        if trimmed ~= \"\" then\n            parts[#parts + 1] = trimmed\n        end\n    end\n\n    -- Empty buffer → clear lyric on this step (original behaviour)\n    if #parts == 0 then\n        local step = state.sequence[stepIndex]\n        if not step then return false end\n        if step.lyric == nil then return false end\n        step.lyric = nil\n        return true\n    end\n\n    -- Preserve the break marker (line/paragraph) from the step being edited\n    local breakSuffix = getLyricBreakSuffix(state.sequence[stepIndex] and state.sequence[stepIndex].lyric)\n\n    -- Assign each part to consecutive steps\n    local changed = false\n    for i, part in ipairs(parts) do\n        local si = stepIndex + (i - 1)\n        if si > state.sequenceLength then break end\n        local step = state.sequence[si]\n        if not step then\n            step = {}\n            state.sequence[si] = step\n        end\n        -- Re-attach the break marker only on the step being edited (i == 1)\n        local newLyric = part .. ((i == 1) and breakSuffix or \"\")\n        if step.lyric ~= newLyric then\n            step.lyric = newLyric\n            changed = true\n        end\n    end\n\n    return changed\nend\n\n-- Forward declarations and early function definitions\nlocal autoSavePending = false\nlocal autoSaveDelayFrames = 60  -- 1 second at 60 FPS\nlocal autoSaveMaxDelayFrames = 300 -- Force save at least every 5 seconds\nlocal autoSaveCountdown = 0\nlocal autoSaveMaxCountdown = 0\nlocal eagerSaveIntervalMs = 500\nlocal eagerSaveDelayFrames = 5\nlocal eagerSaveMaxDelayFrames = 30\nlocal lastEagerSequenceSaveAt = nil\n\nlocal function shouldUseEagerSequenceSave()\n    return isPlaydate or isRunningOnMacDesktop()\nend\n\n-- Universal case-insensitive solfege name → note index (0-based) lookup\n-- Covers all scale modes (major, minor variants)\nSOLFEGE_NAME_TO_NOTE = {\n    [\"do\"] = 0, [\"di\"] = 1, [\"ra\"] = 1,\n    [\"re\"] = 2, [\"ri\"] = 3, [\"me\"] = 3,\n    [\"mi\"] = 4,\n    [\"fa\"] = 5, [\"fi\"] = 6, [\"se\"] = 6,\n    [\"sol\"] = 7, [\"si\"] = 8, [\"le\"] = 8,\n    [\"la\"] = 9, [\"li\"] = 10, [\"te\"] = 10,\n    [\"ti\"] = 11,\n    [\"do'\"] = 12,\n    [\"rest\"] = 13, [\"r\"] = 13, [\"--\"] = 13,\n}\n\n-- Key name → root note index (chromatic, 0=C)\nSOLFEGE_KEY_TO_ROOT = {\n    [\"c\"]=0, [\"c#\"]=1, [\"db\"]=1, [\"d\"]=2, [\"d#\"]=3, [\"eb\"]=3,\n    [\"e\"]=4, [\"f\"]=5, [\"f#\"]=6, [\"gb\"]=6, [\"g\"]=7, [\"g#\"]=8,\n    [\"ab\"]=8, [\"a\"]=9, [\"a#\"]=10, [\"bb\"]=10, [\"b\"]=11,\n}\n\n-- Scale alias → internal scale mode string\nSOLFEGE_SCALE_ALIAS = {\n    [\"major\"]=\"major\", [\"maj\"]=\"major\",\n    [\"minor\"]=\"natural_minor\", [\"min\"]=\"natural_minor\", [\"natural_minor\"]=\"natural_minor\",\n    [\"harmonic_minor\"]=\"harmonic_minor\", [\"harm\"]=\"harmonic_minor\",\n    [\"melodic_minor\"]=\"melodic_minor\", [\"mel\"]=\"melodic_minor\",\n    [\"all\"]=\"all\",\n}\n\n-- Returns the scale key for the first SOLFEGE_SCALE_DROPDOWN_ITEMS entry whose label\n-- starts with `s` (case-insensitive). Returns nil if s is empty or no match.\n_SCALE_TYPEAHEAD_ITEMS = {\n    {key=\"major\",          label=\"major\"},\n    {key=\"natural_minor\",  label=\"natural minor\"},\n    {key=\"harmonic_minor\", label=\"harmonic minor\"},\n    {key=\"melodic_minor\",  label=\"melodic minor\"},\n    {key=\"all\",            label=\"all syllables\"},\n    {key=\"custom\",         label=\"custom\"},\n}\nfunction _findScaleMatch(s)\n    if not s or s == \"\" then return nil end\n    local lower = s:lower()\n    for _, item in ipairs(_SCALE_TYPEAHEAD_ITEMS) do\n        if item.label:sub(1, #lower) == lower then\n            return item.key\n        end\n    end\n    return nil\nend\n\n-- Autocomplete keyword items (scale syllables are added dynamically from current scale)\n_AC_KEYWORDS = {\n    \"Scale:\", \"BPM:\", \"Meter:\", \"Loop:\", \"Length:\", \"Oct:\", \"Octave:\", \"Default:\", \"Transpose:\", \"T:\",\n    \"Scale:major\", \"Scale:natural_minor\", \"Scale:harmonic_minor\", \"Scale:melodic_minor\", \"Scale:all\",\n    \"T:+0\", \"T:+2\", \"T:+5\", \"T:-2\", \"T:-5\",\n    \"--\", \"||\",\n}\n-- Chromatic positions (0-based) that are diatonic for each scale mode\n_SCALE_DIATONIC_POS = {\n    major          = {0, 2, 4, 5, 7, 9, 11},\n    natural_minor  = {0, 2, 3, 5, 7, 8, 10},\n    harmonic_minor = {0, 2, 3, 5, 7, 8, 11},\n    melodic_minor  = {0, 2, 3, 5, 7, 9, 11},\n    all            = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},\n    custom         = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},\n}\n\nfunction _solfegeCurrentWord(buf, cursor)\n    local pos = cursor or (#buf + 1)\n    local wordEnd = pos - 1\n    local wordStart = pos\n    while wordStart > 1 do\n        local ch = buf:sub(wordStart - 1, wordStart - 1)\n        if ch == \" \" or ch == \"\\n\" or ch == \"\\t\" then break end\n        wordStart = wordStart - 1\n    end\n    return buf:sub(wordStart, wordEnd), wordStart\nend\n\nfunction _solfegeStepTokenRange(buf, stepIndex)\n    if not buf or #buf == 0 or not stepIndex or stepIndex < 1 then return nil end\n    if (state.solfegeTextMode or \"both\") ~= \"steps\" then return nil end\n\n    local pos = 1\n    local stepCount = 0\n    local inHeader = true\n\n    while pos <= #buf do\n        while pos <= #buf do\n            local ch = buf:sub(pos, pos)\n            if ch ~= \" \" and ch ~= \"\\t\" and ch ~= \"\\n\" then break end\n            if ch == \"\\n\" then inHeader = false end\n            pos = pos + 1\n        end\n        if pos > #buf then break end\n\n        local tokenStart = pos\n        local ch = buf:sub(pos, pos)\n        if ch == \"<\" then\n            while pos <= #buf and buf:sub(pos, pos) ~= \">\" do pos = pos + 1 end\n            if pos <= #buf then pos = pos + 1 end\n            while pos <= #buf and not buf:sub(pos, pos):match(\"[%s]\") do pos = pos + 1 end\n        elseif ch == \"{\" then\n            while pos <= #buf and buf:sub(pos, pos) ~= \"}\" do pos = pos + 1 end\n            if pos <= #buf then pos = pos + 1 end\n            while pos <= #buf and not buf:sub(pos, pos):match(\"[%s]\") do pos = pos + 1 end\n        else\n            while pos <= #buf and not buf:sub(pos, pos):match(\"[%s]\") do pos = pos + 1 end\n        end\n        local tokenEnd = pos - 1\n\n        if not inHeader then\n            stepCount = stepCount + 1\n            if stepCount == stepIndex then\n                return tokenStart, tokenEnd\n            end\n        end\n    end\n    return nil\nend\n\n-- Returns stepIndex, tokenStart, tokenEnd for the step at/before buffer position pos.\n-- Used by left/right arrow step-navigation in the text editor.\nfunction _solfegeStepAtPos(buf, pos)\n    if (state.solfegeTextMode or \"both\") ~= \"steps\" then return nil end\n    if not buf or #buf == 0 then return nil end\n    local p = 1\n    local stepCount = 0\n    local inHeader = true\n    local lastIdx, lastS, lastE = nil, nil, nil\n    while p <= #buf do\n        while p <= #buf do\n            local ch = buf:sub(p, p)\n            if ch ~= \" \" and ch ~= \"\\t\" and ch ~= \"\\n\" then break end\n            if ch == \"\\n\" then inHeader = false end\n            p = p + 1\n        end\n        if p > #buf then break end\n        local tS = p\n        local ch = buf:sub(p, p)\n        if ch == \"<\" then\n            while p <= #buf and buf:sub(p, p) ~= \">\" do p = p + 1 end\n            if p <= #buf then p = p + 1 end\n            while p <= #buf and not buf:sub(p, p):match(\"[%s]\") do p = p + 1 end\n        elseif ch == \"{\" then\n            while p <= #buf and buf:sub(p, p) ~= \"}\" do p = p + 1 end\n            if p <= #buf then p = p + 1 end\n            while p <= #buf and not buf:sub(p, p):match(\"[%s]\") do p = p + 1 end\n        else\n            while p <= #buf and not buf:sub(p, p):match(\"[%s]\") do p = p + 1 end\n        end\n        local tE = p - 1\n        if not inHeader then\n            stepCount = stepCount + 1\n            lastIdx, lastS, lastE = stepCount, tS, tE\n            if pos <= tE then\n                return stepCount, tS, tE\n            end\n        end\n    end\n    return lastIdx, lastS, lastE\nend\n\nfunction _updateSolfegeStepHighlight()\n    if (state.solfegeTextMode or \"both\") ~= \"steps\" then\n        state._solfegeStepHighlightStart = nil\n        state._solfegeStepHighlightEnd = nil\n        return\n    end\n    if state.solfegeInputActive then\n        state._solfegeStepHighlightStart = nil\n        state._solfegeStepHighlightEnd = nil\n        return\n    end\n    local buf = state.solfegeInputBuffer or \"\"\n    local stepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n    local s, e = _solfegeStepTokenRange(buf, stepIndex)\n    state._solfegeStepHighlightStart = s\n    state._solfegeStepHighlightEnd = e\nend\n\n-- Cycle the solfege syllable token at the cursor position up or down one scale step.\n-- direction: 1 = higher note (Up key), -1 = lower note (Down key)\n-- shiftHeld: if true, do nothing (caller wants text selection instead)\n-- Returns true if a syllable was cycled; false to fall through to line navigation.\nfunction _stepsSyllableCycleAtCursor(direction, shiftHeld)\n    if (state.solfegeTextMode or \"steps\") ~= \"steps\" then return false end\n    if shiftHeld then return false end\n    if not state.solfegeInputActive then return false end\n\n    local buf = state.solfegeInputBuffer or \"\"\n    local cur = state.solfegeInputCursor\n\n    local word, wordStart = _solfegeCurrentWord(buf, cur)\n    if not word or #word == 0 then return false end\n\n    local keyMatch = word:match(\"^[Kk]ey:(.+)$\")\n    if keyMatch then\n        local rootIdx = SOLFEGE_KEY_TO_ROOT[keyMatch:lower()]\n        if rootIdx ~= nil then\n            _pushSolfegeTextUndoState(true)\n            adjustRootNote(direction)\n            local newBuf = state.solfegeInputBuffer or \"\"\n            local newKeyToken = newBuf:match(\"^(Key:[A-Za-z#]+)\")\n            if newKeyToken then\n                state.solfegeInputCursor = #newKeyToken + 1\n            end\n            state.solfegeSelAnchor = nil\n            state.solfegeSelFocus  = nil\n            return true\n        end\n    end\n\n    local bpmMatch = word:match(\"^[Bb][Pp][Mm]:(%d+)$\")\n    if bpmMatch then\n        _pushSolfegeTextUndoState(true)\n        setTempo((state.tempo or 120) + direction * 5)\n        local newBuf = state.solfegeInputBuffer or \"\"\n        local bpmTokenStart = newBuf:find(\"[Bb][Pp][Mm]:%d+\")\n        if bpmTokenStart then\n            local bpmToken = newBuf:match(\"(BPM:%d+)\", bpmTokenStart)\n            state.solfegeInputCursor = bpmTokenStart + #(bpmToken or \"\")\n        end\n        state.solfegeSelAnchor = nil\n        state.solfegeSelFocus  = nil\n        return true\n    end\n\n    if word:match(\"^[Mm]eter:%d+/%d+$\") then\n        _pushSolfegeTextUndoState(true)\n        cycleTimeSignature(direction)\n        local newBuf = state.solfegeInputBuffer or \"\"\n        local meterTokenStart = newBuf:find(\"[Mm]eter:%d+/%d+\")\n        if meterTokenStart then\n            local meterToken = newBuf:match(\"(Meter:%d+/%d+)\", meterTokenStart)\n            state.solfegeInputCursor = meterTokenStart + #(meterToken or \"\")\n        end\n        state.solfegeSelAnchor = nil\n        state.solfegeSelFocus  = nil\n        return true\n    end\n\n    if word:match(\"^[Ll]oop:.+$\") then\n        _pushSolfegeTextUndoState(true)\n        state.loopPlayback = not state.loopPlayback\n        _syncKeyLineInTextBuffer()\n        local newBuf = state.solfegeInputBuffer or \"\"\n        local loopTokenStart = newBuf:find(\"[Ll]oop:[A-Za-z]+\")\n        if loopTokenStart then\n            local loopToken = newBuf:match(\"(Loop:[A-Za-z]+)\", loopTokenStart)\n            state.solfegeInputCursor = loopTokenStart + #loopToken\n        end\n        state.solfegeSelAnchor = nil\n        state.solfegeSelFocus  = nil\n        return true\n    end\n\n    if word:match(\"^[Ll]ength:[A-Za-z]+$\") then\n        _pushSolfegeTextUndoState(true)\n        state.showNoteLengths = not (state.showNoteLengths ~= false)\n        markSequenceDirty()\n        _updateSolfegeStepHighlight()\n        local newBuf = state.solfegeInputBuffer or \"\"\n        local lenTokenStart = newBuf:find(\"[Ll]ength:[A-Za-z]+\")\n        if lenTokenStart then\n            local lenToken = newBuf:match(\"(Length:[A-Za-z]+)\", lenTokenStart)\n            state.solfegeInputCursor = lenTokenStart + #lenToken\n        end\n        state.solfegeSelAnchor = nil\n        state.solfegeSelFocus  = nil\n        return true\n    end\n\n    if word:match(\"^[Oo]ct:[A-Za-z]+$\") then\n        _pushSolfegeTextUndoState(true)\n        state.showOctaveNumbers = not (state.showOctaveNumbers ~= false)\n        markSequenceDirty()\n        _updateSolfegeStepHighlight()\n        local newBuf = state.solfegeInputBuffer or \"\"\n        local octTokenStart = newBuf:find(\"[Oo]ct:[A-Za-z]+\")\n        if octTokenStart then\n            local octToken = newBuf:match(\"(Oct:[A-Za-z]+)\", octTokenStart)\n            state.solfegeInputCursor = octTokenStart + #octToken\n        end\n        state.solfegeSelAnchor = nil\n        state.solfegeSelFocus  = nil\n        return true\n    end\n\n    if word:match(\"^[Dd]efault:.+$\") then\n        _pushSolfegeTextUndoState(true)\n        local opts = core.stepBeatsOptions\n        local curIdx = core.getStepBeatsOptionIndex(state.stepBeats or 1)\n        local newIdx = curIdx + direction\n        if newIdx < 1 then newIdx = #opts end\n        if newIdx > #opts then newIdx = 1 end\n        core.setStepBeats(state, opts[newIdx])\n        markSequenceDirty()\n        _syncKeyLineInTextBuffer()\n        local newBuf = state.solfegeInputBuffer or \"\"\n        local defTokenStart = newBuf:find(\"[Dd]efault:[%d/%.t]+\")\n        if defTokenStart then\n            local defToken = newBuf:match(\"(Default:[%d/%.t]+)\", defTokenStart)\n            state.solfegeInputCursor = defTokenStart + #(defToken or \"\")\n        end\n        state.solfegeSelAnchor = nil\n        state.solfegeSelFocus  = nil\n        return true\n    end\n\n    -- Parse: optional \"[\", syllable letters (incl. apostrophe/hyphen), optional octave\n    -- digit, optional /duration, optional \"]\"\n    local pfx, syl, octStr, durSuffix, sfx =\n        word:match(\"^(%[?)([A-Za-z][A-Za-z'%-]*)(%d?)(/?[^%]%[]*)(%]?)$\")\n    if not syl then return false end  -- header tokens (Key:, BPM:, --) fail here\n\n    local notes = state.solfegeNotes or core.getSolfegeNotes(state.solfegeScale or \"major\")\n    local nameToIdx = {}\n    for i, name in ipairs(notes) do\n        local k = name:lower()\n        if not nameToIdx[k] then nameToIdx[k] = i - 1 end\n    end\n\n    local noteValue = nameToIdx[syl:lower()]\n    if noteValue == nil then return false end  -- unrecognized word\n    if noteValue == 13   then return false end  -- Rest — don't cycle\n\n    local oct = (octStr ~= \"\" and tonumber(octStr)) or (state.currentOctave or 4)\n\n    local nextNote, nextOct = _scaleOps.stepNote(noteValue, oct, direction)\n    if nextNote == nil then return false end\n\n    -- Skip over Do' (note 12, the octave-boundary duplicate) so cycling goes\n    -- Ti → Do(next octave) and Do → Ti(prev octave) without a Do' stop.\n    if nextNote == 12 then\n        if direction > 0 then\n            nextNote = 0\n            nextOct = math.min(nextOct + 1, 7)\n        else\n            nextNote = 11  -- Ti, nextOct already decremented by stepNote\n        end\n    end\n\n    local newSylName = notes[nextNote + 1]\n    if not newSylName or newSylName == \"Rest\" then return false end\n\n    local newWord = pfx .. newSylName .. tostring(nextOct) .. durSuffix .. sfx\n\n    _pushSolfegeTextUndoState(true)\n\n    local wordEnd = wordStart + #word - 1\n    local newBuf = buf:sub(1, wordStart - 1) .. newWord .. buf:sub(wordEnd + 1)\n    state.solfegeInputBuffer = newBuf\n\n    -- Place cursor just after the new token so the next up/down still finds it.\n    local newTokenEnd = wordStart + #newWord - 1\n    if newTokenEnd >= #newBuf then\n        state.solfegeInputCursor = nil\n    else\n        state.solfegeInputCursor = newTokenEnd + 1\n    end\n\n    -- Keep selection covering the new token so the step stays highlighted.\n    state.solfegeSelAnchor = wordStart\n    state.solfegeSelFocus = newTokenEnd + 1\n\n    -- Sync currentStep to match this token's position.\n    local stepIdx = _solfegeStepAtPos(newBuf, wordStart)\n    if stepIdx then state.currentStep = stepIdx end\n\n    _requestLiveApply(true)\n    return true\nend\n\nfunction _updateSolfegeAutocomplete()\n    local mode = state.solfegeTextMode or \"steps\"\n    if not state.solfegeInputActive or mode == \"lyrics\" then\n        state._solfegeAutocomplete = nil; state._solfegeTemplatePicker = nil; state._solfegeAutocompleteCacheKey = nil; return\n    end\n    if state.solfegeSelAnchor and state.solfegeSelFocus then\n        state._solfegeAutocomplete = nil; state._solfegeTemplatePicker = nil; state._solfegeAutocompleteCacheKey = nil; return\n    end\n    local buf = state.solfegeInputBuffer or \"\"\n    local cursor = state.solfegeInputCursor\n    -- Fast path: skip cache-key build when buffer/cursor/scale unchanged\n    if buf == state._acLastBuf and cursor == state._acLastCur\n       and not state._acDirty and state._solfegeAutocompleteCacheKey then\n        return\n    end\n    state._acLastBuf = buf\n    state._acLastCur = cursor\n    local word, wordStart = _solfegeCurrentWord(buf, cursor)\n    if #word < 1 then\n        state._solfegeAutocomplete = nil; state._solfegeTemplatePicker = nil; state._solfegeAutocompleteCacheKey = nil; return\n    end\n    local cacheKey = table.concat({\n        buf,\n        tostring(cursor or \"\"),\n        tostring(state.solfegeScale or \"major\"),\n        tostring(state._acDirty or false),\n        tostring(state.userSolfegeTemplates and #state.userSolfegeTemplates or 0),\n    }, \"\\31\")\n    if state._solfegeAutocompleteCacheKey == cacheKey then\n        return\n    end\n    state._solfegeAutocompleteCacheKey = cacheKey\n    if word:sub(1, 1) == \"/\" then\n        _updateSolfegeTemplatePicker(word:sub(2), wordStart)\n        state._solfegeAutocomplete = nil\n        return\n    end\n    state._solfegeTemplatePicker = nil\n    local lower = word:lower()\n    -- Build item list: diatonic scale syllables first, then keywords\n    local scale = state.solfegeScale or \"major\"\n    local positions = _SCALE_DIATONIC_POS[scale] or _SCALE_DIATONIC_POS.major\n    local notes = state.solfegeNotes or core.getSolfegeNotes(scale)\n    local scaleSyls = {}\n    for _, pos in ipairs(positions) do\n        local syl = notes[pos + 1]\n        if syl and syl ~= \"Rest\" and syl ~= \"Do'\" then\n            scaleSyls[#scaleSyls + 1] = syl\n        end\n    end\n    local items = {}\n    for _, s in ipairs(scaleSyls) do items[#items + 1] = s end\n    for _, k in ipairs(_AC_KEYWORDS) do items[#items + 1] = k end\n\n    local matches = {}\n    -- Ascending/descending scale macros matched by trigger prefix\n    local ascText  = table.concat(scaleSyls, \" \")\n    local descSyls = {}\n    for i = #scaleSyls, 1, -1 do descSyls[#descSyls + 1] = scaleSyls[i] end\n    local descText = table.concat(descSyls, \" \")\n    local function triggerMatches(trigger)\n        return #lower >= 1 and trigger:sub(1, #lower) == lower\n    end\n    if triggerMatches(\"ascending\") then\n        matches[#matches + 1] = {text = ascText, label = \"\\xe2\\x86\\x91 \" .. ascText}\n    end\n    if triggerMatches(\"descending\") then\n        matches[#matches + 1] = {text = descText, label = \"\\xe2\\x86\\x93 \" .. descText}\n    end\n    local ascDescText = ascText .. \" \" .. descText\n    if triggerMatches(\"ascending to descending\") or triggerMatches(\"arc\") then\n        matches[#matches + 1] = {text = ascDescText, label = \"\\xe2\\x86\\x91\\xe2\\x86\\x93 \" .. ascDescText}\n    end\n    for _, item in ipairs(items) do\n        if #matches >= 6 then break end\n        if item:lower():sub(1, #lower) == lower and item:lower() ~= lower then\n            matches[#matches + 1] = item\n        end\n    end\n    if #matches == 0 then\n        state._solfegeAutocomplete = nil; return\n    end\n    local notesKey = table.concat(scaleSyls, \",\")\n    local prev = state._solfegeAutocomplete\n    local prevSel = prev and prev.sel or 1\n    local dirty = state._acDirty\n    state._acDirty = false\n    local scaleChanged = dirty or not prev or prev.scale ~= scale or prev.notesKey ~= notesKey\n    if not scaleChanged and prev and #prev.items == #matches then\n        local same = true\n        for i, v in ipairs(matches) do\n            local a = type(v) == \"table\" and v.text or v\n            local b = type(prev.items[i]) == \"table\" and prev.items[i].text or prev.items[i]\n            if a ~= b then same = false; break end\n        end\n        if same then return end\n    end\n    state._solfegeAutocomplete = {\n        items = matches, sel = math.min(prevSel, #matches),\n        wordStart = wordStart, scale = scale, notesKey = notesKey,\n    }\nend\n\nfunction _acceptSolfegeAutocomplete()\n    local ac = state._solfegeAutocomplete\n    if not ac then return end\n    local raw = ac.items[ac.sel or 1]\n    if not raw then return end\n    local item = type(raw) == \"table\" and raw.text or raw\n    local buf = state.solfegeInputBuffer or \"\"\n    local pos = state.solfegeInputCursor or (#buf + 1)\n    local before = buf:sub(1, ac.wordStart - 1)\n    local after = buf:sub(pos)\n    local newBuf = before .. item .. after\n    state.solfegeInputBuffer = newBuf\n    local newCur = #before + #item\n    state.solfegeInputCursor = (newCur >= #newBuf) and nil or (newCur + 1)\n    state._solfegeAutocomplete = nil\n    _requestLiveApply(true)\n    state._solfegeLastCursorActivity = os.clock()\nend\n\nlocal function _extractSylsFromText(text)\n    local syls = {}\n    for token in (text or \"\"):gmatch(\"%S+\") do\n        -- Skip meta tokens (Key:, BPM:, etc.) and special syntax\n        if not token:find(\":\") and token:sub(1,1) ~= \"<\" and token:sub(1,1) ~= \"{\" and token:sub(1,1) ~= \"[\" and token ~= \"||\" then\n            local syl = token:match(\"^([A-Za-z][A-Za-z'%-]*)\")\n            if syl then\n                local low = syl:lower()\n                local noteIdx = SOLFEGE_NAME_TO_NOTE[low]\n                if noteIdx and noteIdx ~= 13 then  -- exclude rests\n                    syls[#syls + 1] = low\n                end\n            end\n        end\n    end\n    return syls\nend\n\nlocal function _templatePrefixScore(bufSyls, templateText)\n    if #bufSyls == 0 then return 0 end\n    local tmplSyls = _extractSylsFromText(templateText)\n    if #tmplSyls == 0 or #bufSyls > #tmplSyls then return 0 end\n    for i = 1, #bufSyls do\n        if bufSyls[i] ~= tmplSyls[i] then return 0 end\n    end\n    return #bufSyls  -- full prefix match: score = number of syllables matched\nend\n\nfunction _updateSolfegeTemplatePicker(query, wordStart)\n    local lower = query:lower()\n    -- Extract syllables typed before the \"/\" trigger for prediction scoring\n    local buf = state.solfegeInputBuffer or \"\"\n    local bufBefore = buf:sub(1, wordStart - 1)\n    local bufSyls = _extractSylsFromText(bufBefore)\n\n    local items = {}\n    local cats = ui.SOLFEGE_TEMPLATE_CATEGORIES or {}\n    for _, cat in ipairs(cats) do\n        for _, t in ipairs(cat.templates) do\n            if lower == \"\" or t.name:lower():find(lower, 1, true) then\n                local score = _templatePrefixScore(bufSyls, t.text)\n                items[#items + 1] = {label = t.name, text = t.text, category = cat.name, matchScore = score}\n            end\n        end\n    end\n    local userTemplates = state.userSolfegeTemplates or {}\n    for _, t in ipairs(userTemplates) do\n        if lower == \"\" or t.name:lower():find(lower, 1, true) then\n            local score = _templatePrefixScore(bufSyls, t.text or \"\")\n            items[#items + 1] = {label = t.name, text = t.text, category = \"My Templates\", matchScore = score}\n        end\n    end\n    if #items == 0 then\n        state._solfegeTemplatePicker = nil\n        return\n    end\n    -- Stable sort: predicted (score > 0) first, sorted by score desc, then rest in original order\n    table.sort(items, function(a, b)\n        if a.matchScore ~= b.matchScore then return a.matchScore > b.matchScore end\n        return false\n    end)\n    local prev = state._solfegeTemplatePicker\n    local prevSel = prev and prev.sel or 1\n    state._solfegeTemplatePicker = {\n        items = items,\n        sel = math.min(prevSel, #items),\n        wordStart = wordStart,\n        query = query,\n        scrollTop = prev and prev.scrollTop or 0,\n    }\nend\n\nfunction _acceptSolfegeTemplatePicker()\n    local tp = state._solfegeTemplatePicker\n    if not tp then return end\n    local raw = tp.items[tp.sel or 1]\n    if not raw then return end\n    local text = raw.text\n    local buf = state.solfegeInputBuffer or \"\"\n    local pos = state.solfegeInputCursor or (#buf + 1)\n    local before = buf:sub(1, tp.wordStart - 1)\n    local after = buf:sub(pos)\n    local newBuf = before .. text .. \" \" .. after\n    state.solfegeInputBuffer = newBuf\n    local newCur = #before + #text + 1\n    state.solfegeInputCursor = (newCur >= #newBuf) and nil or (newCur + 1)\n    state._solfegeTemplatePicker = nil\n    _requestLiveApply(true)\n    state._solfegeLastCursorActivity = os.clock()\nend\n\n-- Root note index (0-11) → canonical key name for serialization\nSOLFEGE_ROOT_TO_NAME = {\"C\",\"C#\",\"D\",\"D#\",\"E\",\"F\",\"F#\",\"G\",\"G#\",\"A\",\"A#\",\"B\"}\n\n-- Duration beats value → compact suffix string for serialization\nSOLFEGE_BEATS_TO_SUFFIX = {\n    [4]=\"1\", [2]=\"2\", [1]=\"4\", [0.5]=\"8\", [0.25]=\"16\", [0.125]=\"32\",\n    [0.75]=\"d8\", [1.5]=\"d4\", [3]=\"d2\", [6]=\"d1\",\n}\n-- Use a small epsilon for tuplet comparison\nlocal function beatsToSuffix(b)\n    local exact = SOLFEGE_BEATS_TO_SUFFIX[b]\n    if exact then return exact end\n    if math.abs(b - 1/3) < 0.001 then return \"8t\"  end\n    if math.abs(b - 2/3) < 0.001 then return \"4t\"  end\n    if math.abs(b - 1/6) < 0.001 then return \"16t\" end\n    if math.abs(b - 4/3) < 0.001 then return \"2t\"  end\n    return nil\nend\n\n-- Reverse lookup: short label string → beat value\nlocal SOLFEGE_SHORT_LABEL_TO_BEATS = {\n    [\"1/1\"]  = 4,\n    [\"1/2.\"] = 3,\n    [\"1/2\"]  = 2,\n    [\"1/4.\"] = 1.5,\n    [\"1/4\"]  = 1,\n    [\"1/8.\"] = 0.75,\n    [\"1/8\"]  = 0.5,\n    [\"1/8t\"] = 1/3,\n    [\"1/16\"] = 0.25,\n    [\"1/32\"] = 0.125,\n}\n\n-- Serialize the current active sequence to a text string\nfunction serializeSequenceToText()\n    local textMode = state.solfegeTextMode or \"both\"\n    local seq = state.sequence\n    local len = state.sequenceLength or 0\n    local notes = state.solfegeNotes or core.getSolfegeNotes(state.solfegeScale)\n    local defaultOct = state.currentOctave or 4\n    local defaultLen = state.stepBeats or 1\n    local lines = {}\n    local curLine = {}\n    -- Prepend Key: line in steps mode so users can see and edit the key from text\n    if textMode == \"steps\" and len > 0 then\n        local rootIdx = (state.rootNote or 0) % 12\n        local keyName = SOLFEGE_ROOT_TO_NAME[rootIdx + 1] or \"C\"\n        local oct = state.currentOctave or 4\n        local bpm = math.floor(state.tempo or 120)\n        local meter = tostring(state.meterNumerator or 4) .. \"/\" .. tostring(state.meterDenominator or 4)\n        local loopState = state.loopPlayback and \"On\" or \"Off\"\n        local transposeSuffix = \"\"\n        if (state.solfegeTranspose or 0) ~= 0 then\n            local t = state.solfegeTranspose\n            transposeSuffix = \" T:\" .. (t > 0 and \"+\" or \"\") .. t\n        end\n        local lengthState = (state.showNoteLengths ~= false) and \"On\" or \"Off\"\n        local octState = (state.showOctaveNumbers ~= false) and \"On\" or \"Off\"\n        local defaultLabel = core.getStepBeatsShortLabel(state.stepBeats or 1)\n        table.insert(lines, \"Key:\" .. keyName .. \" Octave:\" .. oct .. \" BPM:\" .. bpm .. \" Meter:\" .. meter .. \" Loop:\" .. loopState .. \" Length:\" .. lengthState .. \" Oct:\" .. octState .. \" Default:\" .. defaultLabel .. transposeSuffix)\n    end\n    for i = 1, len do\n        local step = seq[i]\n        if not step then\n            if textMode ~= \"lyrics\" then\n                table.insert(curLine, \"--\")\n            end\n        else\n            local token\n            if textMode == \"lyrics\" then\n                token = (step.lyric and step.lyric ~= \"\") and step.lyric or nil\n            elseif step.randomAlternatives and #step.randomAlternatives > 0 then\n                local altParts = {}\n                for _, alt in ipairs(step.randomAlternatives) do\n                    local aName = alt.note == 13 and \"--\" or (notes[alt.note + 1] or \"--\")\n                    local aOct = (alt.note ~= 13 and alt.octave and (textMode ~= \"steps\" or state.showOctaveNumbers ~= false)) and tostring(alt.octave) or \"\"\n                    local aDur = \"\"\n                    if alt.length and ((textMode == \"steps\" and state.showNoteLengths ~= false) or (textMode ~= \"steps\" and math.abs(alt.length - defaultLen) > 0.001)) then\n                        local s = beatsToSuffix(alt.length)\n                        aDur = s and (\"/\" .. s) or \"\"\n                    end\n                    altParts[#altParts + 1] = aName .. aOct .. aDur\n                end\n                local loopPfx = (state.stepLoopStart == i) and \"[\" or \"\"\n                local loopSfx = (state.stepLoopEnd   == i) and \"]\" or \"\"\n                token = loopPfx .. \"{\" .. table.concat(altParts, \"|\") .. \"}\" .. loopSfx\n            elseif core.isChord(step) then\n                local chordParts = {}\n                for _, nd in ipairs(step.notes) do\n                    local cName = nd.note == 13 and \"--\" or (notes[nd.note + 1] or \"--\")\n                    local cOct = (nd.note ~= 13 and nd.octave and (textMode ~= \"steps\" or state.showOctaveNumbers ~= false)) and tostring(nd.octave) or \"\"\n                    chordParts[#chordParts + 1] = cName .. cOct\n                end\n                local durSuffix = \"\"\n                local len_ = step.length or defaultLen\n                if (textMode == \"steps\" and state.showNoteLengths ~= false) or (textMode ~= \"steps\" and math.abs(len_ - defaultLen) > 0.001) then\n                    local s = beatsToSuffix(len_)\n                    durSuffix = s and (\"/\" .. s) or \"\"\n                end\n                local loopPfx = (state.stepLoopStart == i) and \"[\" or \"\"\n                local loopSfx = (state.stepLoopEnd   == i) and \"]\" or \"\"\n                token = loopPfx .. \"<\" .. table.concat(chordParts, \" \") .. \">\" .. durSuffix .. loopSfx\n            else\n                local noteIdx = step.note\n                local oct = step.octave or defaultOct\n                local len_ = step.length or defaultLen\n                local name = noteIdx == 13 and \"--\" or (notes[noteIdx + 1] or \"--\")\n                local octSuffix = (noteIdx ~= 13 and (textMode ~= \"steps\" or state.showOctaveNumbers ~= false)) and tostring(oct) or \"\"\n                local durSuffix = \"\"\n                if (textMode == \"steps\" and state.showNoteLengths ~= false) or (textMode ~= \"steps\" and math.abs(len_ - defaultLen) > 0.001) then\n                    local s = beatsToSuffix(len_)\n                    durSuffix = s and (\"/\" .. s) or \"\"\n                end\n                local lyricSuffix = (textMode ~= \"steps\" and step.lyric and step.lyric ~= \"\") and (\"|\" .. step.lyric) or \"\"\n                local loopPfx = (state.stepLoopStart == i) and \"[\" or \"\"\n                local loopSfx = (state.stepLoopEnd   == i) and \"]\" or \"\"\n                token = loopPfx .. name .. octSuffix .. durSuffix .. lyricSuffix .. loopSfx\n            end\n            if token ~= nil then\n                table.insert(curLine, token)\n            end\n            local rb = state.rowBreakAfterStep\n            local shouldBreakRow = false\n            if type(rb) == \"table\" then\n                for _, bp in ipairs(rb) do if i == bp then shouldBreakRow = true; break end end\n            elseif type(rb) == \"number\" and rb >= 1 and i % rb == 0 then\n                shouldBreakRow = true\n            end\n            if shouldBreakRow and i < len then\n                if #curLine > 0 then\n                    table.insert(lines, table.concat(curLine, \" \"))\n                    curLine = {}\n                end\n                -- extra blank line for paragraph breaks\n                if step.paragraphEnd and textMode ~= \"lyrics\" then\n                    table.insert(lines, \"\")\n                end\n            end\n        end\n    end\n    if #curLine > 0 then\n        table.insert(lines, table.concat(curLine, \" \"))\n    end\n    return table.concat(lines, \"\\n\")\nend\n\nenterLyricsScreen = function(options)\n    options = options or {}\n    if options.commitInput and state.solfegeInputActive then\n        commitSolfegeInput()\n    end\n    if not state._lyricsForcesHideSteps then\n        state._lyricsHideStepsSaved = state.hideSteps\n    end\n    state._lyricsForcesHideSteps = true\n    state.hideSteps = true\n    state.solfegeTextMode = \"lyrics\"\n    state.showSolfegeTextInput = true\n    state.templateStepsOnly = false\n    state.solfegeInputActive = false\n\n    if options.refreshBuffer then\n        state.solfegeInputBuffer = serializeSequenceToText()\n        state._solfegeSeqText = state.solfegeInputBuffer\n    else\n        state.solfegeInputBuffer = state.solfegeInputBuffer or \"\"\n        state._solfegeSeqText = state._solfegeSeqText or state.solfegeInputBuffer\n    end\nend\n\nfunction leaveLyricsOnlyHide()\n    if state._lyricsForcesHideSteps then\n        state.hideSteps = state._lyricsHideStepsSaved and true or false\n        state._lyricsHideStepsSaved = nil\n        state._lyricsForcesHideSteps = nil\n    end\nend\n\nfunction refreshSolfegeBufferForViewMode(mode, options)\n    options = options or {}\n    state._modeSwOldBuf = state.solfegeInputBuffer or \"\"\n    if options.commitInput and state.solfegeInputActive then\n        commitSolfegeInput()\n    end\n    state.solfegeTextMode = mode\n    if options.refreshBuffer then\n        state.solfegeInputBuffer = serializeSequenceToText()\n        state._solfegeSeqText = state.solfegeInputBuffer\n        if state.solfegeInputBuffer == \"\" and state._modeSwOldBuf ~= \"\" then\n            state.solfegeInputBuffer = state._modeSwOldBuf\n            state._solfegeSeqText = state.solfegeInputBuffer\n        end\n    end\n    state._modeSwOldBuf = nil\n    state.solfegeInputActive = false\nend\n\nenterStepsOnlyScreen = function(options)\n    leaveLyricsOnlyHide()\n    refreshSolfegeBufferForViewMode(\"steps\", options)\n    state.hideSteps = false\n    state.showSolfegeTextInput = true\n    state.singSolfegeMode = false\n    state.templateStepsOnly = true\n    if type(syncPitchRecognitionForSingSolfege) == \"function\" then\n        syncPitchRecognitionForSingSolfege()\n    end\nend\n\nenterStepsLyricsScreen = function(options)\n    leaveLyricsOnlyHide()\n    refreshSolfegeBufferForViewMode(\"both\", options)\n    state.hideSteps = false\n    state.showSolfegeTextInput = true\n    state.singSolfegeMode = false\n    state.templateStepsOnly = false\n    if type(syncPitchRecognitionForSingSolfege) == \"function\" then\n        syncPitchRecognitionForSingSolfege()\n    end\nend\n\nenterComposeMode = function(options)\n    leaveLyricsOnlyHide()\n    refreshSolfegeBufferForViewMode(\"both\", options)\n    state.showSolfegeTextInput = false\n    state.singSolfegeMode = false\n    state.templateStepsOnly = false\n    if type(syncPitchRecognitionForSingSolfege) == \"function\" then\n        syncPitchRecognitionForSingSolfege()\n    end\nend\n\nenterSingMode = function(options)\n    leaveLyricsOnlyHide()\n    refreshSolfegeBufferForViewMode(\"both\", options)\n    state.showSolfegeTextInput = false\n    state.templateStepsOnly = false\n    if not state.singSolfegeMode then\n        resetSingSolfegeOctaveOffset()\n    end\n    state.singSolfegeMode = true\n    if type(syncPitchRecognitionForSingSolfege) == \"function\" then\n        syncPitchRecognitionForSingSolfege()\n    end\nend\n\n-- Duration suffix → beats value\n-- /1=whole(4) /2=half(2) /4=quarter(1) /8=eighth(0.5) /16=16th(0.25) /32=32nd(0.125)\n-- /8t=1/8 triplet(1/3) /4t=1/4 triplet(2/3) /16t=1/16 triplet(1/6) /2t=1/2 triplet(4/3)\n-- /d8=dotted eighth(0.75) /d4=dotted quarter(1.5) /d2=dotted half(3) /d1=dotted whole(6)\nSOLFEGE_DURATION_MAP = {\n    [\"1\"] = 4,    [\"2\"] = 2,    [\"4\"] = 1,    [\"8\"] = 0.5,  [\"16\"] = 0.25, [\"32\"] = 0.125,\n    [\"8t\"] = 1/3, [\"4t\"] = 2/3, [\"16t\"] = 1/6, [\"2t\"] = 4/3,\n    [\"d8\"] = 0.75, [\"d4\"] = 1.5, [\"d2\"] = 3, [\"d1\"] = 6,\n}\n\n-- Parse a single token like \"Sol4/4|Hello\" → {note, octave, length, lyric}\n-- Format: syllable[octave][/duration][|lyric]\nfunction parseSolfegeToken(token)\n    -- strip leading/trailing whitespace\n    local t = token:match(\"^%s*(.-)%s*$\")\n\n    -- Split off |lyric suffix first\n    local core_part, lyric = t:match(\"^(.-)%|(.+)$\")\n    if not core_part then\n        core_part = t\n        lyric = nil\n    end\n\n    -- special case: \"--\" rest with optional /duration\n    local restDur = core_part:match(\"^%-%-/?(.*)$\")\n    if restDur ~= nil then\n        local len = (restDur ~= \"\" and SOLFEGE_DURATION_MAP[restDur:lower()]) or nil\n        return {note = 13, octave = nil, length = len, lyric = lyric}\n    end\n    -- match: syllable + optional octave digit + optional /duration\n    local syllable, octStr, durStr = core_part:match(\"^([A-Za-z][A-Za-z'%-]*)(%d?)/?(.*)$\")\n    if not syllable then return nil end\n    local noteIdx = SOLFEGE_NAME_TO_NOTE[syllable:lower()]\n    if noteIdx == nil then return nil end\n    local oct = (octStr ~= \"\" and tonumber(octStr)) or nil\n    local len = (durStr ~= \"\" and SOLFEGE_DURATION_MAP[durStr:lower()]) or nil\n    return {note = noteIdx, octave = oct, length = len, lyric = lyric}\nend\n\nfunction _solfegeTextSnapshot()\n    return {\n        text = state.solfegeInputBuffer or \"\",\n        cursor = state.solfegeInputCursor,\n    }\nend\n\nfunction _solfegeTextStateEquals(left, right)\n    if not left or not right then return false end\n    return left.text == right.text and left.cursor == right.cursor\nend\n\nfunction _ensureSolfegeTextHistory()\n    state.solfegeTextUndoStack = state.solfegeTextUndoStack or {}\n    state.solfegeTextRedoStack = state.solfegeTextRedoStack or {}\nend\n\nfunction _primeSolfegeTextHistory()\n    _ensureSolfegeTextHistory()\n    local undoStack = state.solfegeTextUndoStack\n    local snapshot = _solfegeTextSnapshot()\n    if not _solfegeTextStateEquals(undoStack[#undoStack], snapshot) then\n        undoStack[#undoStack + 1] = snapshot\n        if #undoStack > 64 then\n            table.remove(undoStack, 1)\n        end\n    end\nend\n\n_lastUndoPushTime = 0\n_UNDO_COALESCE_INTERVAL = 0.3\n\nfunction _pushSolfegeTextUndoState(forceNewEntry)\n    if state._solfegeRestoringTextHistory then return end\n    local now = os.clock()\n    if not forceNewEntry and (now - _lastUndoPushTime) < _UNDO_COALESCE_INTERVAL then\n        _ensureSolfegeTextHistory()\n        local undoStack = state.solfegeTextUndoStack\n        if #undoStack > 0 then\n            undoStack[#undoStack] = _solfegeTextSnapshot()\n        else\n            _primeSolfegeTextHistory()\n        end\n    else\n        _primeSolfegeTextHistory()\n    end\n    _lastUndoPushTime = now\n    state.solfegeTextRedoStack = {}\nend\n\nfunction _restoreSolfegeTextSnapshot(snapshot)\n    if not snapshot then return false end\n    state._solfegeRestoringTextHistory = true\n    state.solfegeInputBuffer = snapshot.text or \"\"\n    state.solfegeInputCursor = snapshot.cursor\n    state._solfegeLastCursorActivity = os.clock()\n    _requestLiveApply(true)\n    state._solfegeRestoringTextHistory = false\n    return true\nend\n\nfunction undoSolfegeTextEdit()\n    _ensureSolfegeTextHistory()\n    local undoStack = state.solfegeTextUndoStack\n    if #undoStack == 0 then return false end\n    local current = _solfegeTextSnapshot()\n    local target = table.remove(undoStack)\n    if _solfegeTextStateEquals(target, current) then\n        if #undoStack == 0 then\n            undoStack[#undoStack + 1] = target\n            return false\n        end\n        target = table.remove(undoStack)\n    end\n    local redoStack = state.solfegeTextRedoStack or {}\n    if not _solfegeTextStateEquals(redoStack[#redoStack], current) then\n        redoStack[#redoStack + 1] = current\n    end\n    state.solfegeTextRedoStack = redoStack\n    return _restoreSolfegeTextSnapshot(target)\nend\n\nfunction redoSolfegeTextEdit()\n    _ensureSolfegeTextHistory()\n    local redoStack = state.solfegeTextRedoStack\n    if #redoStack == 0 then return false end\n    local current = _solfegeTextSnapshot()\n    local target = table.remove(redoStack)\n    local undoStack = state.solfegeTextUndoStack or {}\n    if not _solfegeTextStateEquals(undoStack[#undoStack], current) then\n        undoStack[#undoStack + 1] = current\n    end\n    state.solfegeTextUndoStack = undoStack\n    return _restoreSolfegeTextSnapshot(target)\nend\n\nfunction expandVariables(buf)\n    local vars = {}\n    local outputLines = {}\n    for line in (buf .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do\n        local varName, varValue = line:match(\"^%s*(%$[A-Za-z_][A-Za-z0-9_]*)%s*=%s*(.+)$\")\n        if varName then\n            vars[varName:lower()] = varValue\n        else\n            outputLines[#outputLines + 1] = line\n        end\n    end\n    if next(vars) == nil then return table.concat(outputLines, \"\\n\") end\n    local result = table.concat(outputLines, \"\\n\")\n    for _ = 1, 8 do\n        local changed = false\n        for name, value in pairs(vars) do\n            local escaped = name:gsub(\"(%$)\", \"%%%1\")\n            local new = result:gsub(escaped, value)\n            if new ~= result then changed = true; result = new end\n        end\n        if not changed then break end\n    end\n    return result\nend\n\nfunction expandRepetitions(buf, depth)\n    depth = depth or 0\n    if depth > 4 then return buf end\n    local result = {}\n    local i = 1\n    local len = #buf\n    while i <= len do\n        local ch = buf:sub(i, i)\n        if ch == \"(\" then\n            local parenDepth = 1\n            local start = i + 1\n            i = i + 1\n            while i <= len and parenDepth > 0 do\n                local c = buf:sub(i, i)\n                if c == \"(\" then parenDepth = parenDepth + 1\n                elseif c == \")\" then parenDepth = parenDepth - 1 end\n                i = i + 1\n            end\n            if parenDepth > 0 then\n                result[#result + 1] = \"(\"\n                i = start\n            else\n                local inner = buf:sub(start, i - 2)\n                local rep = buf:match(\"^x(%d+)\", i)\n                local count = rep and tonumber(rep) or 1\n                if rep then i = i + 1 + #rep end\n                count = math.min(count, 128)\n                local expanded = expandRepetitions(inner, depth + 1)\n                for r = 1, count do\n                    if r > 1 then result[#result + 1] = \" \" end\n                    result[#result + 1] = expanded\n                end\n            end\n        else\n            result[#result + 1] = ch\n            i = i + 1\n        end\n    end\n    return table.concat(result)\nend\n\nfunction liveApplySequenceFromText()\n    local textMode = state.solfegeTextMode or \"both\"\n    local rawBuf = state.solfegeInputBuffer or \"\"\n    if rawBuf == _liveApplyLastBuf and textMode == _liveApplyLastMode then return end\n    _liveApplyLastBuf = rawBuf\n    _liveApplyLastMode = textMode\n    local buf = rawBuf:gsub(\"^[%s\\n]+\", \"\")\n    -- Strip leading = or + prefix\n    if buf:sub(1,1) == \"=\" or buf:sub(1,1) == \"+\" then\n        buf = buf:sub(2):gsub(\"^[%s\\n]+\", \"\")\n    end\n    if buf == \"\" then state._parseErrorCount = 0 end\n\n    -- Pre-process: expand variables and repetitions (steps mode only)\n    if textMode ~= \"lyrics\" then\n        buf = expandVariables(buf)\n        buf = expandRepetitions(buf)\n    end\n\n    -- Lyrics mode: apply words as lyrics; create rest steps if none exist yet\n    if textMode == \"lyrics\" then\n        local defaultOct = state.currentOctave or 4\n        local defaultLen = state.stepBeats or 1\n        local wordIdx = 0\n        local rowBreaks = {}\n        local lineNum = 0\n        local pendingParaBreak = false\n        local oldLen = state.sequenceLength or 0\n        for line in (buf .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do\n            lineNum = lineNum + 1\n            local lineHasTokens = line:match(\"%S+\") ~= nil\n            if lineNum > 1 and wordIdx > 0 then\n                if lineHasTokens then\n                    if rowBreaks[#rowBreaks] ~= wordIdx then\n                        rowBreaks[#rowBreaks + 1] = wordIdx\n                    end\n                    if pendingParaBreak and state.sequence[wordIdx] then\n                        state.sequence[wordIdx].paragraphEnd = true\n                    end\n                    pendingParaBreak = false\n                else\n                    pendingParaBreak = true\n                end\n            end\n            for token in line:gmatch(\"%S+\") do\n                if token == \"||\" then\n                    -- paragraph break marker in lyrics mode\n                    if wordIdx > 0 then\n                        if rowBreaks[#rowBreaks] ~= wordIdx then\n                            rowBreaks[#rowBreaks + 1] = wordIdx\n                        end\n                        if state.sequence[wordIdx] then\n                            state.sequence[wordIdx].paragraphEnd = true\n                        end\n                    end\n                else\n                    wordIdx = wordIdx + 1\n                    if not state.sequence[wordIdx] then\n                        state.sequence[wordIdx] = {\n                            note = 13, octave = defaultOct, length = defaultLen\n                        }\n                    end\n                    state.sequence[wordIdx].lyric = (token == \"--\") and nil or token\n                end\n            end\n        end\n        -- Wipe rest steps beyond what was typed only if they were auto-created rests\n        -- (don't shrink a longer sequence the user built in steps mode)\n        for i = wordIdx + 1, oldLen do\n            -- keep existing non-rest steps; remove auto-placeholder rests\n        end\n        local newLen = math.max(oldLen, wordIdx)\n        state.sequenceLength = newLen\n        state.sequenceLengths[state.activeSequenceIndex] = newLen\n        if #rowBreaks == 0 then\n            state.rowBreakAfterStep = nil\n        elseif #rowBreaks == 1 then\n            state.rowBreakAfterStep = rowBreaks[1]\n        else\n            state.rowBreakAfterStep = rowBreaks\n        end\n        state.singleRowGrid = (#rowBreaks == 0 and wordIdx > 0)\n        state._solfegeSeqText = nil\n        if state.solfegeSpellCheck then _spellCheckPending = true end\n                autoSavePending = true\n        autoSaveCountdown = autoSaveDelayFrames\n        return\n    end\n\n    -- Steps mode: use the lyrics snapshot (captured at focus-time) so partial-token\n    -- rewrites mid-typing don't progressively lose lyrics from other step positions\n    local savedLyrics = {}\n    if textMode == \"steps\" then\n        if state._lyricsSnapshot then\n            for i, v in pairs(state._lyricsSnapshot) do savedLyrics[i] = v end\n        else\n            local oldLen = state.sequenceLength or 0\n            for i = 1, oldLen do\n                if state.sequence[i] then savedLyrics[i] = state.sequence[i].lyric end\n            end\n        end\n    end\n\n    local defaultOct = state.currentOctave or 4\n    local defaultLen = state.stepBeats or 1\n    local steps = {}\n\n    local rowBreaks = {}\n    local stepIdx = 0\n    local lineNum = 0\n    local pendingParaBreak = false\n    local loopStartIdx = nil\n    local loopEndIdx   = nil\n    local pendingLoopStart = false\n    local chordAccum = nil\n    local _parseErrors = 0\n    for line in (buf .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do\n        lineNum = lineNum + 1\n        -- Only mark a row break when the NEW line actually has tokens.\n        local lineHasTokens = line:match(\"%S+\") ~= nil\n        if lineNum > 1 and stepIdx > 0 then\n            if lineHasTokens then\n                -- row break from newline (deduplicate if || already added one)\n                if rowBreaks[#rowBreaks] ~= stepIdx then\n                    rowBreaks[#rowBreaks + 1] = stepIdx\n                end\n                -- paragraph break if a blank line preceded this section\n                if pendingParaBreak and steps[stepIdx] then\n                    steps[stepIdx].paragraphEnd = true\n                end\n                pendingParaBreak = false\n            else\n                -- blank line → will be a paragraph break before the next section\n                pendingParaBreak = true\n            end\n        end\n        for rawToken in line:gmatch(\"%S+\") do\n            -- Strip loop range markers before any other handling\n            local token = rawToken\n            local isLoopStart = token:sub(1,1) == \"[\"\n            local isLoopEnd   = #token > 0 and token:sub(-1) == \"]\"\n            if isLoopStart then token = token:sub(2) end\n            if isLoopEnd and #token > 0 then token = token:sub(1,-2) end\n            -- Standalone \"[\" or \"]\" tokens\n            if token == \"\" then\n                if isLoopStart then pendingLoopStart = true end\n                if isLoopEnd and stepIdx > 0 then loopEndIdx = stepIdx end\n            elseif token == \"||\" then\n                if stepIdx > 0 then\n                    if steps[stepIdx] then steps[stepIdx].paragraphEnd = true end\n                    -- inline paragraph break also creates a row break\n                    if rowBreaks[#rowBreaks] ~= stepIdx then\n                        rowBreaks[#rowBreaks + 1] = stepIdx\n                    end\n                end\n            elseif token == \"|\" then\n                rowBreaks[#rowBreaks + 1] = stepIdx\n            elseif token:match(\"^[Kk]ey:(.+)$\") then\n                -- No side effects during live typing (no playback, no savePreferences)\n                local root = SOLFEGE_KEY_TO_ROOT[token:match(\"^[Kk]ey:(.+)$\"):lower()]\n                if root then state.rootNote = root; state.pendingRootNote = root end\n            elseif token:match(\"^[Ss]cale:(.+)$\") then\n                local sm = SOLFEGE_SCALE_ALIAS[token:match(\"^[Ss]cale:(.+)$\"):lower()]\n                if sm then state.solfegeScale = sm; refreshSolfegeNotes() end\n            elseif token:match(\"^[Bb][Pp][Mm]:(%d+%.?%d*)$\") then\n                local v = tonumber(token:match(\"^[Bb][Pp][Mm]:(%d+%.?%d*)$\"))\n                if v and v >= 20 and v <= 300 then setTempo(v) end\n            elseif token:match(\"^[Mm]eter:(%d+)/(%d+)$\") then\n                local num, den = token:match(\"^[Mm]eter:(%d+)/(%d+)$\")\n                num = tonumber(num)\n                den = tonumber(den)\n                if num and den and core.getTimeSignatureOptionIndex(num, den) then\n                    core.setTimeSignature(state, num, den)\n                end\n            elseif token:match(\"^[Ll]oop:(.+)$\") then\n                if not state.isPlaying then\n                    local loopValue = tostring(token:match(\"^[Ll]oop:(.+)$\") or \"\"):lower()\n                    if loopValue == \"on\" or loopValue == \"true\" or loopValue == \"1\" then\n                        state.loopPlayback = true\n                    elseif loopValue == \"off\" or loopValue == \"false\" or loopValue == \"0\" then\n                        state.loopPlayback = false\n                    end\n                end\n            elseif token:match(\"^[Ll]ength:(.+)$\") then\n                local lenValue = tostring(token:match(\"^[Ll]ength:(.+)$\") or \"\"):lower()\n                if lenValue == \"on\" or lenValue == \"true\" or lenValue == \"1\" then\n                    state.showNoteLengths = true\n                elseif lenValue == \"off\" or lenValue == \"false\" or lenValue == \"0\" then\n                    state.showNoteLengths = false\n                end\n            elseif token:match(\"^[Oo]ct:(.+)$\") then\n                local octValue = tostring(token:match(\"^[Oo]ct:(.+)$\") or \"\"):lower()\n                if octValue == \"on\" or octValue == \"true\" or octValue == \"1\" then\n                    state.showOctaveNumbers = true\n                elseif octValue == \"off\" or octValue == \"false\" or octValue == \"0\" then\n                    state.showOctaveNumbers = false\n                end\n            elseif token:match(\"^[Dd]efault:(.+)$\") then\n                local defValue = token:match(\"^[Dd]efault:(.+)$\")\n                local beats = SOLFEGE_SHORT_LABEL_TO_BEATS[defValue]\n                if beats then\n                    core.setStepBeats(state, beats)\n                    defaultLen = beats\n                end\n            elseif token:match(\"^[Oo]ctave:(%d)$\") then\n                defaultOct = tonumber(token:match(\"^[Oo]ctave:(%d)$\")) or defaultOct\n                state.currentOctave = defaultOct\n            elseif token:match(\"^[Tt]ranspose?:([%+%-]?%d+)$\") or token:match(\"^[Tt]:([%+%-]?%d+)$\") then\n                local val = tonumber(token:match(\":([%+%-]?%d+)$\")) or 0\n                state.solfegeTranspose = math.max(-24, math.min(24, val))\n            else\n                -- Chord start/end markers\n                local isChordStart = token:sub(1,1) == \"<\"\n                local isChordEnd = #token > 0 and token:sub(-1) == \">\"\n                if isChordStart then token = token:sub(2) end\n                if isChordEnd and #token > 0 then token = token:sub(1, -2) end\n\n                if isChordStart and not chordAccum then\n                    chordAccum = {}\n                end\n\n                -- Random pick: {Do|Re|Mi}\n                local randomAlts = token:match(\"^{(.+)}$\")\n                if randomAlts and not chordAccum then\n                    local alternatives = {}\n                    for alt in randomAlts:gmatch(\"[^|]+\") do\n                        local parsed = parseSolfegeToken(alt)\n                        if parsed then alternatives[#alternatives + 1] = parsed end\n                    end\n                    if #alternatives > 0 then\n                        stepIdx = stepIdx + 1\n                        steps[stepIdx] = {\n                            randomAlternatives = alternatives,\n                            note = alternatives[1].note,\n                            octave = alternatives[1].octave,\n                            length = alternatives[1].length,\n                        }\n                        if pendingLoopStart or isLoopStart then\n                            loopStartIdx = stepIdx; pendingLoopStart = false\n                        end\n                        if isLoopEnd then loopEndIdx = stepIdx end\n                    else\n                        _parseErrors = _parseErrors + 1\n                    end\n                elseif chordAccum then\n                    -- Accumulate notes into chord; parse duration from last token\n                    local chordDur = nil\n                    if isChordEnd then\n                        local durPart = token:match(\"/(.+)$\")\n                        if durPart then\n                            chordDur = SOLFEGE_DURATION_MAP[durPart:lower()]\n                            token = token:match(\"^(.-)/.+$\") or token\n                        end\n                    end\n                    if token ~= \"\" then\n                        local parsed = parseSolfegeToken(token)\n                        if parsed and parsed.note ~= 13 then\n                            if #chordAccum < 8 then\n                                chordAccum[#chordAccum + 1] = {\n                                    note = parsed.note,\n                                    octave = parsed.octave or defaultOct\n                                }\n                            end\n                        elseif not parsed then\n                            _parseErrors = _parseErrors + 1\n                        end\n                    end\n                    if isChordEnd then\n                        if #chordAccum > 0 then\n                            stepIdx = stepIdx + 1\n                            steps[stepIdx] = {\n                                notes = chordAccum,\n                                note = chordAccum[1].note,\n                                octave = chordAccum[1].octave,\n                                length = chordDur,\n                            }\n                            if pendingLoopStart or isLoopStart then\n                                loopStartIdx = stepIdx; pendingLoopStart = false\n                            end\n                            if isLoopEnd then loopEndIdx = stepIdx end\n                        end\n                        chordAccum = nil\n                    end\n                else\n                    local parsed = parseSolfegeToken(token)\n                    if parsed then\n                        stepIdx = stepIdx + 1\n                        steps[stepIdx] = parsed\n                        if pendingLoopStart or isLoopStart then\n                            loopStartIdx = stepIdx\n                            pendingLoopStart = false\n                        end\n                        if isLoopEnd then loopEndIdx = stepIdx end\n                    else\n                        _parseErrors = _parseErrors + 1\n                    end\n                end\n            end\n        end\n        -- Flush unclosed chord at end of line\n        if chordAccum and #chordAccum > 0 then\n            stepIdx = stepIdx + 1\n            steps[stepIdx] = {\n                notes = chordAccum,\n                note = chordAccum[1].note,\n                octave = chordAccum[1].octave,\n                length = nil,\n            }\n        end\n        chordAccum = nil\n    end\n    state._parseErrorCount = _parseErrors\n    -- Apply loop range from text markers (only set, never clear, during live typing)\n    if loopStartIdx and loopEndIdx then\n        state.stepLoopStart = math.min(loopStartIdx, loopEndIdx)\n        state.stepLoopEnd   = math.max(loopStartIdx, loopEndIdx)\n    elseif loopStartIdx then\n        state.stepLoopStart = loopStartIdx\n        state.stepLoopEnd   = stepIdx\n    elseif loopEndIdx then\n        state.stepLoopStart = 1\n        state.stepLoopEnd   = loopEndIdx\n    end\n    state.singleRowGrid = (#rowBreaks == 0 and stepIdx > 0)\n\n    local oldLen = state.sequenceLength or 0\n    for i = 1, math.max(oldLen, stepIdx) do state.sequence[i] = nil end\n\n    for i = 1, stepIdx do\n        local s = steps[i]\n        local step = {\n            note         = s.note,\n            octave       = s.octave or defaultOct,\n            length       = s.length or defaultLen,\n            lyric        = s.lyric or savedLyrics[i] or nil,\n            paragraphEnd = s.paragraphEnd or nil,\n        }\n        if s.notes then step.notes = s.notes end\n        if s.randomAlternatives then step.randomAlternatives = s.randomAlternatives end\n        state.sequence[i] = step\n    end\n    state.sequenceLength = stepIdx\n    state.sequenceLengths[state.activeSequenceIndex] = stepIdx\n    -- Store full break array for variable row lengths; single break stays as number for compat\n    if #rowBreaks == 0 then\n        state.rowBreakAfterStep = nil\n    elseif #rowBreaks == 1 then\n        state.rowBreakAfterStep = rowBreaks[1]\n    else\n        state.rowBreakAfterStep = rowBreaks\n    end\n    state._solfegeSeqText = nil\n    if state.solfegeSpellCheck then _spellCheckPending = true end\n    autoSavePending = true\n    autoSaveCountdown = autoSaveDelayFrames\nend\n\nfunction commitSolfegeInput()\n    local textMode = state.solfegeTextMode or \"both\"\n    local buf = state.solfegeInputBuffer or \"\"\n    buf = buf:match(\"^%s*(.-)%s*$\")  -- trim\n    if buf == \"\" then\n        state.solfegeInputActive = false\n        state.solfegeInputCursor = nil\n        state._lyricsSnapshot = nil\n        return\n    end\n\n    -- Lyrics mode: apply each word as a lyric; create rest steps if needed\n    if textMode == \"lyrics\" then\n        recordStepHistoryIfNeeded()\n        local defaultOct = state.currentOctave or 4\n        local defaultLen = state.stepBeats or 1\n        local oldLen = state.sequenceLength or 0\n        local wordIdx = 0\n        for token in buf:gmatch(\"%S+\") do\n            wordIdx = wordIdx + 1\n            if not state.sequence[wordIdx] then\n                state.sequence[wordIdx] = {\n                    note = 13, octave = defaultOct, length = defaultLen\n                }\n            end\n            state.sequence[wordIdx].lyric = (token == \"--\") and nil or token\n        end\n        local newLen = math.max(oldLen, wordIdx)\n        state.sequenceLength = newLen\n        state.sequenceLengths[state.activeSequenceIndex] = newLen\n        state.solfegeInputActive = false\n        state.solfegeInputCursor = nil\n        state._lyricsSnapshot = nil\n        markSequenceDirty()\n        return\n    end\n\n    -- Replace is the default. Use \"+\" prefix to append instead.\n    local replaceMode = true\n    if buf:sub(1, 1) == \"+\" then\n        replaceMode = false\n        buf = buf:sub(2):match(\"^%s*(.-)%s*$\")\n    elseif buf:sub(1, 1) == \"=\" then\n        buf = buf:sub(2):match(\"^%s*(.-)%s*$\")\n    end\n\n    -- Pre-process: expand variables and repetitions\n    buf = expandVariables(buf)\n    buf = expandRepetitions(buf)\n\n    recordStepHistoryIfNeeded()\n\n    -- Steps mode: save existing lyrics before wiping\n    local savedLyricsCommit = {}\n    if textMode == \"steps\" and replaceMode then\n        local oldLen = state.sequenceLength or 0\n        for i = 1, oldLen do\n            if state.sequence[i] then savedLyricsCommit[i] = state.sequence[i].lyric end\n        end\n    end\n\n    -- If replacing, wipe the sequence\n    if replaceMode then\n        local len = state.sequenceLength or 0\n        for i = 1, len do state.sequence[i] = nil end\n        state.sequenceLength = 0\n        state.sequenceLengths[state.activeSequenceIndex] = 0\n    end\n\n    local defaultOct = state.currentOctave or 4\n    local defaultLen = state.stepBeats or 1\n    local steps = {}\n    local rowBreak = nil\n    local stepIdx = 0\n    local loopStartIdx    = nil\n    local loopEndIdx      = nil\n    local pendingLoopStart = false\n\n    for rawToken in buf:gmatch(\"%S+\") do\n        -- Strip loop range markers before any other handling\n        local token = rawToken\n        local isLoopStart = token:sub(1,1) == \"[\"\n        local isLoopEnd   = #token > 0 and token:sub(-1) == \"]\"\n        if isLoopStart then token = token:sub(2) end\n        if isLoopEnd and #token > 0 then token = token:sub(1,-2) end\n        -- Standalone \"[\" or \"]\"\n        if token == \"\" then\n            if isLoopStart then pendingLoopStart = true end\n            if isLoopEnd and stepIdx > 0 then loopEndIdx = stepIdx end\n            goto continue\n        end\n        -- Row break / paragraph markers\n        if token == \"||\" then\n            if stepIdx > 0 and steps[stepIdx] then steps[stepIdx].paragraphEnd = true end\n            goto continue\n        elseif token == \"|\" then\n            rowBreak = stepIdx\n            goto continue\n        end\n        -- Meta command: key:X\n        local keyName = token:match(\"^[Kk]ey:(.+)$\")\n        if keyName then\n            local root = SOLFEGE_KEY_TO_ROOT[keyName:lower()]\n            if root then _scaleOps.setRootOnly(root) end\n            goto continue\n        end\n        -- Meta command: scale:X\n        local scaleName = token:match(\"^[Ss]cale:(.+)$\")\n        if scaleName then\n            local mode = SOLFEGE_SCALE_ALIAS[scaleName:lower()]\n            if mode then\n                state.solfegeScale = mode\n                refreshSolfegeNotes()\n                savePreferences()\n            end\n            goto continue\n        end\n        -- Meta command: bpm:N\n        local bpmVal = token:match(\"^[Bb][Pp][Mm]:(%d+%.?%d*)$\")\n        if bpmVal then\n            local v = tonumber(bpmVal)\n            if v then setTempo(v); savePreferences() end\n            goto continue\n        end\n        -- Meta command: meter:N/D\n        local meterNum, meterDen = token:match(\"^[Mm]eter:(%d+)/(%d+)$\")\n        if meterNum and meterDen then\n            local num = tonumber(meterNum)\n            local den = tonumber(meterDen)\n            if num and den and core.getTimeSignatureOptionIndex(num, den) then\n                core.setTimeSignature(state, num, den)\n                savePreferences()\n            end\n            goto continue\n        end\n        -- Meta command: loop:on|off (skip during playback so stale header doesn't override loop state)\n        local loopValue = token:match(\"^[Ll]oop:(.+)$\")\n        if loopValue then\n            if not state.isPlaying then\n                local normalized = tostring(loopValue):lower()\n                if normalized == \"on\" or normalized == \"true\" or normalized == \"1\" then\n                    state.loopPlayback = true\n                    savePreferences()\n                elseif normalized == \"off\" or normalized == \"false\" or normalized == \"0\" then\n                    state.loopPlayback = false\n                    savePreferences()\n                end\n            end\n            goto continue\n        end\n        -- Meta command: octave:N\n        local octVal = token:match(\"^[Oo]ctave:(%d)$\")\n        if octVal then\n            defaultOct = tonumber(octVal) or defaultOct\n            state.currentOctave = defaultOct\n            goto continue\n        end\n        -- Meta command: transpose:N / T:N\n        if token:match(\"^[Tt]ranspose?:([%+%-]?%d+)$\") or token:match(\"^[Tt]:([%+%-]?%d+)$\") then\n            local val = tonumber(token:match(\":([%+%-]?%d+)$\")) or 0\n            state.solfegeTranspose = math.max(-24, math.min(24, val))\n            goto continue\n        end\n        -- Chord start/end markers\n        do\n            local isChordStart = token:sub(1,1) == \"<\"\n            local isChordEnd = #token > 0 and token:sub(-1) == \">\"\n            if isChordStart then token = token:sub(2) end\n            if isChordEnd and #token > 0 then token = token:sub(1, -2) end\n\n            if isChordStart and not _commitChordAccum then\n                _commitChordAccum = {}\n            end\n\n            -- Random pick: {Do|Re|Mi}\n            local randomAlts = token:match(\"^{(.+)}$\")\n            if randomAlts and not _commitChordAccum then\n                local alternatives = {}\n                for alt in randomAlts:gmatch(\"[^|]+\") do\n                    local parsed = parseSolfegeToken(alt)\n                    if parsed then alternatives[#alternatives + 1] = parsed end\n                end\n                if #alternatives > 0 then\n                    stepIdx = stepIdx + 1\n                    steps[stepIdx] = {\n                        randomAlternatives = alternatives,\n                        note = alternatives[1].note,\n                        octave = alternatives[1].octave,\n                        length = alternatives[1].length,\n                    }\n                    if pendingLoopStart or isLoopStart then loopStartIdx = stepIdx; pendingLoopStart = false end\n                    if isLoopEnd then loopEndIdx = stepIdx end\n                end\n                goto continue\n            end\n\n            if _commitChordAccum then\n                local chordDur = nil\n                if isChordEnd then\n                    local durPart = token:match(\"/(.+)$\")\n                    if durPart then\n                        chordDur = SOLFEGE_DURATION_MAP[durPart:lower()]\n                        token = token:match(\"^(.-)/.+$\") or token\n                    end\n                end\n                if token ~= \"\" then\n                    local parsed = parseSolfegeToken(token)\n                    if parsed and parsed.note ~= 13 and #_commitChordAccum < 8 then\n                        _commitChordAccum[#_commitChordAccum + 1] = {\n                            note = parsed.note,\n                            octave = parsed.octave or defaultOct\n                        }\n                    end\n                end\n                if isChordEnd then\n                    if #_commitChordAccum > 0 then\n                        stepIdx = stepIdx + 1\n                        steps[stepIdx] = {\n                            notes = _commitChordAccum,\n                            note = _commitChordAccum[1].note,\n                            octave = _commitChordAccum[1].octave,\n                            length = chordDur,\n                        }\n                        if pendingLoopStart or isLoopStart then loopStartIdx = stepIdx; pendingLoopStart = false end\n                        if isLoopEnd then loopEndIdx = stepIdx end\n                    end\n                    _commitChordAccum = nil\n                end\n                goto continue\n            end\n        end\n        -- Regular solfege token\n        local parsed = parseSolfegeToken(token)\n        if parsed then\n            stepIdx = stepIdx + 1\n            steps[stepIdx] = parsed\n            if pendingLoopStart or isLoopStart then\n                loopStartIdx = stepIdx\n                pendingLoopStart = false\n            end\n            if isLoopEnd then loopEndIdx = stepIdx end\n        end\n        ::continue::\n    end\n    _commitChordAccum = nil\n\n    if rowBreak ~= nil then state.rowBreakAfterStep = rowBreak end\n\n    -- Apply or clear loop range from text markers\n    if loopStartIdx and loopEndIdx then\n        state.stepLoopStart = math.min(loopStartIdx, loopEndIdx)\n        state.stepLoopEnd   = math.max(loopStartIdx, loopEndIdx)\n    elseif loopStartIdx then\n        state.stepLoopStart = loopStartIdx\n        state.stepLoopEnd   = stepIdx\n    elseif loopEndIdx then\n        state.stepLoopStart = 1\n        state.stepLoopEnd   = loopEndIdx\n    else\n        state.stepLoopStart = nil\n        state.stepLoopEnd   = nil\n    end\n\n    if stepIdx > 0 then\n        local baseStep = state.sequenceLength or 0\n        for i = 1, stepIdx do\n            local s = steps[i]\n            local si = baseStep + i\n            local step = {\n                note         = s.note,\n                octave       = s.octave or defaultOct,\n                length       = s.length or defaultLen,\n                lyric        = s.lyric or savedLyricsCommit[i] or nil,\n                paragraphEnd = s.paragraphEnd or nil,\n            }\n            if s.notes then step.notes = s.notes end\n            if s.randomAlternatives then step.randomAlternatives = s.randomAlternatives end\n            state.sequence[si] = step\n        end\n        local newLen = baseStep + stepIdx\n        state.sequenceLength = newLen\n        state.sequenceLengths[state.activeSequenceIndex] = newLen\n    end\n\n    state.solfegeInputActive = false\n    state.solfegeInputCursor = nil\n    state._lyricsSnapshot = nil\n    markSequenceDirty()\nend\n\nfunction markSequenceDirty()\n    recordStepHistoryIfNeeded()\n    state._solfegeSeqText = serializeSequenceToText()\n    local _lnInline = state.lyricNotesPanelOpen and not state.lyricNotesDetached\n    -- In lyrics mode the text buffer IS the source of truth; step data is derived\n    -- from it, not the other way around. Overwriting it here would drop empty-step\n    -- placeholders (they have no lyric to serialize) and corrupt insertions.\n    local _isLyricsMode = (state.solfegeTextMode == \"lyrics\")\n    if not state._preserveInputBuffer and not _lnInline and not _isLyricsMode then\n        if state.solfegeTextMode == \"steps\" then\n            local _oldCursor = state.solfegeInputCursor\n            local _oldSelAnchor = state.solfegeSelAnchor\n            local _oldSelFocus = state.solfegeSelFocus\n            state.solfegeInputBuffer = state._solfegeSeqText\n            if state.solfegeInputActive then\n                local _newLen = #(state.solfegeInputBuffer or \"\")\n                if _oldCursor ~= nil then\n                    state.solfegeInputCursor = (_oldCursor > _newLen) and nil or _oldCursor\n                end\n                if _oldSelAnchor then\n                    state.solfegeSelAnchor = math.max(1, math.min(_oldSelAnchor, _newLen + 1))\n                end\n                if _oldSelFocus then\n                    state.solfegeSelFocus = math.max(1, math.min(_oldSelFocus, _newLen + 1))\n                end\n            end\n        elseif not state.solfegeInputActive then\n            state.solfegeInputBuffer = state._solfegeSeqText\n        end\n        -- Re-derive row layout flags from the updated buffer\n        local _rb = {}; local _si = 0; local _ln = 0\n        local _b = (state.solfegeInputBuffer or \"\"):gsub(\"^[%s\\n]+\",\"\")\n        local _isLyrics = (state.solfegeTextMode == \"lyrics\")\n        for _line in (_b..\"\\n\"):gmatch(\"([^\\n]*)\\n\") do\n            _ln = _ln + 1\n            local _lht = _line:match(\"%S+\") ~= nil\n            if _ln > 1 and _si > 0 and _lht then _rb[#_rb+1] = _si end\n            for _tok in _line:gmatch(\"%S+\") do\n                if _isLyrics then\n                    _si = _si + 1\n                elseif parseSolfegeToken and parseSolfegeToken(_tok) then\n                    _si = _si + 1\n                end\n            end\n        end\n        if #_rb == 0 then\n            state.rowBreakAfterStep = nil\n        elseif #_rb == 1 then\n            state.rowBreakAfterStep = _rb[1]\n        else\n            state.rowBreakAfterStep = _rb\n        end\n        state.singleRowGrid = (#_rb == 0 and _si > 0)\n    end\n    local useEagerSave = shouldUseEagerSequenceSave() and type(saveSequence) == \"function\"\n\n    if useEagerSave then\n        local now = getCurrentTimeMilliseconds()\n        if (not lastEagerSequenceSaveAt) or (now - lastEagerSequenceSaveAt >= eagerSaveIntervalMs) then\n            saveSequence(false)\n            autoSavePending = false\n            autoSaveMaxCountdown = 0\n            lastEagerSequenceSaveAt = now\n            return\n        end\n    end\n\n    if not autoSavePending then\n        autoSavePending = true\n        autoSaveMaxCountdown = useEagerSave and eagerSaveMaxDelayFrames or autoSaveMaxDelayFrames\n    end\n    autoSaveCountdown = useEagerSave and eagerSaveDelayFrames or autoSaveDelayFrames\nend\n\n-- Shift the octave of every note in the active pattern by delta (+1 or -1).\n-- Clamps each note's octave to the valid range [2, 7].\nlocal function shiftPatternOctave(delta)\n    local seq = state.sequence\n    local len = state.sequenceLength or 0\n    local selOnly = state.multiSelectMode and state.selectedSteps and next(state.selectedSteps)\n    for i = 1, len do\n        if not selOnly or state.selectedSteps[i] then\n            local step = seq[i]\n            if step then\n                if step.notes then\n                    -- Chord step: shift each note\n                    for _, nd in ipairs(step.notes) do\n                        nd.octave = math.max(2, math.min(7, (nd.octave or 4) + delta))\n                    end\n                elseif step.note ~= nil and step.note ~= 13 then\n                    -- Single note step (note 13 = rest)\n                    step.octave = math.max(2, math.min(7, (step.octave or 4) + delta))\n                end\n            end\n        end\n    end\n    markSequenceDirty()\nend\n\nlocal refreshDrone  -- Will be defined later\n\n-- Template browser functions\nfunction showTemplateBrowser()\n    state.showingTemplateBrowser = true\n    state.selectedTemplateCategory = 1\n    state.selectedTemplateIndex = 1\nend\n\nfunction closeTemplateBrowser()\n    state.showingTemplateBrowser = false\nend\n\nfunction getTemplateCategories()\n    return templateLibrary.categories\nend\n\nfunction getTemplateList()\n    local categoryName = templateLibrary.categories[state.selectedTemplateCategory]\n    return templateLibrary.getTemplatesByCategory(categoryName)\nend\n\nfunction getSelectedTemplate()\n    local templates = getTemplateList()\n    return templates[state.selectedTemplateIndex]\nend\n\nfunction loadSelectedTemplate()\n    local template = getSelectedTemplate()\n    if template then\n        local previousDarkMode = state.darkMode\n        local previousAcapellaMode = state.acapellaMode\n        local success = templateLibrary.loadTemplate(state, template, core, {stepsOnly = state.templateStepsOnly})\n        if success then\n            closeTemplateBrowser()\n\n            -- Ensure active sequence is properly synced\n            state.sequence = state.sequences[state.activeSequenceIndex]\n            state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\n            state.currentStep = math.min(math.max(1, state.currentStep), math.max(state.sequenceLength, 1))\n\n            -- Sync selectedNote with the current step after template load\n            local _cs = state.sequence[state.currentStep]\n            if _cs then\n                if core.isChord(_cs) then\n                    local fn = _cs.notes and _cs.notes[1]\n                    if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n                elseif _cs.note ~= nil and _cs.note ~= 13 then\n                    state.selectedNote = _cs.note\n                    state.currentOctave = _cs.octave\n                end\n            else\n                state.selectedNote = 0\n            end\n\n            state.solfegeTextMode = \"steps\"\n            state.solfegeScale = \"major\"\n            state.solfegeNotes = core.getSolfegeNotes(\"major\")\n            markSequenceDirty()\n            state.solfegeInputBuffer = serializeSequenceToText()\n            state._solfegeSeqText = state.solfegeInputBuffer\n            state.solfegeInputActive = false\n            if template.settings then\n                if template.settings.darkMode ~= nil and state.darkMode ~= previousDarkMode and gfx then\n                    gfx.setDarkMode(state.darkMode)\n                end\n                if template.settings.acapellaMode ~= nil and state.acapellaMode and not previousAcapellaMode then\n                    stopAllVoices()\n                end\n            end\n            -- Refresh drone if settings changed\n            if refreshDrone then\n                refreshDrone()\n            end\n            return true\n        end\n    end\n    return false\nend\n\nif isPlaydate then\n    function playdate.toggleHeaderSelectionMode()\n        state.headerSelectionMode = not state.headerSelectionMode\n    end\nend\n\n-- Solfège system (chromatic - 12 notes + rest)\nlocal handImages = nil  -- Will be initialized after graphics adapter is created\n\nlocal playbackStopTimer = nil\nlocal playbackClock = {\n    nextStepAt = nil\n}\nheldStepInputMaxMs = 1200\nheldStepInputMaxLength = 8\nmidiHoldInput = {\n    heldNotes = {},  -- list of MIDI note numbers currently held for this step\n    stepIndex = nil,\n    startTime = nil,\n    length = nil,\n}\n\nfunction moveCursorAfterHeldStep(stepIndex, heldLength)\n    if not stepIndex then return end\n    local nextStep = stepIndex + math.max(1, math.ceil(heldLength or 1))\n    state.currentStep = math.min(nextStep, math.min(core.maxSteps, (state.sequenceLength or 0) + 1))\nend\n-- Drag-to-stretch state\nlocal stretchDrag = {\n    active = false,\n    stepIndex = nil,   -- which step is being stretched\n    startCol = nil,    -- column of the step being stretched\n    startRow = nil,    -- row of the step being stretched\n    startMouseX = nil, -- mouseX at mouseDown\n    startLen = nil,    -- step length at mouseDown\n    previewLen = nil,  -- live length while dragging\n    rightEdge = true,  -- true = right edge, false = left edge\n}\n-- Drag-to-gate state\nlocal gateDrag = {\n    active = false,\n    stepIndex = nil,   -- which step is being gate-edited\n    startMouseY = nil, -- mouseY at mouseDown\n    startGate = nil,   -- gate value at mouseDown\n    moved = false,     -- true if mouse moved beyond a small threshold\n}\n-- Drag-to-reorder state (left-click hold then drag)\nlocal reorderDrag = {\n    active = false,\n    stepIndex = nil,   -- which step is being dragged\n    targetIndex = nil, -- where it would be dropped (insert before this index)\n}\n-- Drag-to-select: tracks a drag across steps in multiSelectMode\nlocal selectDrag = {\n    active = false,\n    startIndex = nil,  -- step index where drag began\n}\n-- Pending hold: tracks a mouse-down on a step waiting to become reorder\nlocal holdPending = {\n    active = false,\n    stepIndex = nil,\n    startTime = nil,   -- os.clock() at mouse-down\n    startX = nil,      -- mouse x at mouse-down\n    startY = nil,      -- mouse y at mouse-down\n}\nlocal volumeSliderDrag = {\n    active = false,\n}\nsolfegeDrag = {\n    active = false,\n    startX = nil,\n    startWidth = nil,\n    side = nil,\n}\nsolfegeFloatDrag = {active = false, startX = 0, startY = 0, startPanelX = 0, startPanelY = 0}\nsolfegeFloatResize = {active = false, startX = 0, startY = 0, startW = 0, startH = 0}\nsolfegeBottomDrag = {active = false, startY = 0, startH = 0}\nlocal lnSplitDrag = {active = false, startX = 0, startRatio = 0.38, totalW = 0}\nlocal welcomeScrollDrag = {\n    active = false,\n    dragOffsetY = 0,  -- mouseY - thumbY at mousedown\n}\n-- Drag-to-scrub BPM (click and drag up/down on BPM header)\nbpmDrag = {\n    active = false,\n    startY = nil,   -- mouseY at mouseDown\n    startTempo = nil, -- tempo at mouseDown\n    moved = false,  -- true once drag threshold exceeded\n}\nlocal lyricTokenDrag = {\n    active = false,\n    token = nil,\n    x = nil,\n    y = nil,\n    startX = nil,\n    startY = nil,\n    moved = false,\n    source = nil,\n    stepIndex = nil,\n}\nlocal HOLD_THRESHOLD_S = 0.2   -- seconds to hold before entering reorder mode\nlocal DRAG_THRESHOLD_PX = 5    -- pixels of movement before reorder drag activates\nlocal preferencesFilename = \"preferences.json\"\nlocal startDrone\nlocal stopDrone\nlocal mirrorMuteActive = false\nlocal mirrorMutedVolume = nil\nlocal randomSeeded = false\nlocal autoLockDisabled = false\n\nfunction isUsbConnected()\n    return systemAdapter:isUsbConnected()\nend\n\nfunction setMirrorMute(enabled)\n    if mirrorMuteActive == enabled then\n        return\n    end\n    if enabled then\n        if snd.getVolume then\n            mirrorMutedVolume = snd.getVolume()\n        end\n        if snd.setVolume then\n            snd.setVolume(0)\n        end\n    else\n        if snd.setVolume then\n            snd.setVolume(mirrorMutedVolume or 1)\n        end\n        mirrorMutedVolume = nil\n    end\n    mirrorMuteActive = enabled\nend\n\nfunction setAutoLockDisabled(enabled)\n    if autoLockDisabled == enabled then\n        return\n    end\n    systemAdapter:setAutoLockDisabled(enabled)\n    autoLockDisabled = enabled\nend\n\nfunction seedRandom()\n    if randomSeeded then\n        return\n    end\n    math.randomseed(systemAdapter:getCurrentTimeMilliseconds())\n    randomSeeded = true\nend\n\nfunction getCurrentTimeMilliseconds()\n    return systemAdapter:getCurrentTimeMilliseconds()\nend\n \nfunction resetPlaybackClock(now)\n    if not state.isPlaying then\n        return\n    end\n    playbackClock.nextStepAt = (now or getCurrentTimeMilliseconds()) + state.stepDuration\nend\n\n-- When true, setTempo was called by tickMidiClockIn (not the user).\n-- Prevents clock-sync calls from triggering a manual-override lockout.\nlocal _midiClockApplying = false\n-- Timestamp (ms) until which tickMidiClockIn should not override the tempo.\n-- Set whenever setTempo is called from a user action so manual changes \"win\"\n-- for 3 seconds before external clock sync can take back control.\nlocal _midiClockManualOverrideUntil = 0\n\nfunction setTempo(newTempo)\n    core.setTempo(state, newTempo)\n    resetPlaybackClock()\n    if not _midiClockApplying then\n        -- User-initiated change: lock out MIDI clock sync for 3 seconds\n        _midiClockManualOverrideUntil = getCurrentTimeMilliseconds() + 3000\n    end\n    _syncKeyLineInTextBuffer()\nend\n\nfunction setStepBeats(newStepBeats)\n    core.setStepBeats(state, newStepBeats)\n    resetPlaybackClock()\nend\n\nfunction cycleTimeSignature(direction)\n    local options = core.timeSignatureOptions or {}\n    if #options == 0 then return end\n\n    local currentIndex = core.getTimeSignatureOptionIndex(state.meterNumerator, state.meterDenominator)\n    local nextIndex = currentIndex + (direction or 1)\n    if nextIndex > #options then nextIndex = 1 end\n    if nextIndex < 1 then nextIndex = #options end\n\n    local signature = options[nextIndex]\n    core.setTimeSignature(state, signature.numerator, signature.denominator)\n    _syncKeyLineInTextBuffer()\nend\n\n-- Adjust sequence length by delta bars (when stepsPerBar is valid) or delta steps.\nfunction adjustBarCount(delta)\n    local spb = core.getStepsPerBar(state.meterNumerator, state.meterDenominator, state.stepBeats)\n    local curLen = state.sequenceLength or 0\n    local newLen\n    if spb and spb >= 1 then\n        local maxLen = math.floor(core.maxSteps / spb) * spb\n        if delta > 0 then\n            -- Snap to next bar boundary after curLen (always adds exactly stepsPerBar)\n            newLen = math.min(maxLen, math.ceil((curLen + 1) / spb) * spb)\n        else\n            -- Snap to previous bar boundary before curLen\n            newLen = math.max(0, math.floor(math.max(0, curLen - 1) / spb) * spb)\n        end\n    else\n        newLen = math.max(0, math.min(core.maxSteps, curLen + delta))\n    end\n    state.sequenceLength = newLen\n    state.sequenceLengths[state.activeSequenceIndex] = newLen\n    state.currentStep = math.min(math.max(1, state.currentStep), math.max(newLen, 1))\n    markSequenceDirty()\nend\n\n-- Adjust sequence length by delta beats (only when stepBeats < 1).\nfunction adjustBeatCount(delta)\n    local spb = core.getStepsPerBeat(state.stepBeats)\n    if not spb then return end\n    local curLen = state.sequenceLength or 0\n    local newLen\n    if delta > 0 then\n        newLen = math.min(core.maxSteps, math.ceil((curLen + 1) / spb) * spb)\n    else\n        newLen = math.max(0, math.floor(math.max(0, curLen - 1) / spb) * spb)\n    end\n    state.sequenceLength = newLen\n    state.sequenceLengths[state.activeSequenceIndex] = newLen\n    state.currentStep = math.min(math.max(1, state.currentStep), math.max(newLen, 1))\n    markSequenceDirty()\nend\n\nfunction commitBarEdit()\n    local v = tonumber(state.barInputBuffer)\n    if v and v >= 0 then\n        local spb = core.getStepsPerBar(state.meterNumerator, state.meterDenominator, state.stepBeats)\n        local newLen\n        if spb then\n            newLen = math.max(0, math.min(core.maxSteps, math.floor(v) * spb))\n        else\n            newLen = math.max(0, math.min(core.maxSteps, math.floor(v)))\n        end\n        state.sequenceLength = newLen\n        state.sequenceLengths[state.activeSequenceIndex] = newLen\n        state.currentStep = math.min(math.max(1, state.currentStep), math.max(newLen, 1))\n        markSequenceDirty()\n    end\n    state.barEditing = false\n    state.barInputBuffer = nil\nend\n\nfunction commitBeatEdit()\n    local v = tonumber(state.beatInputBuffer)\n    if v and v >= 0 then\n        local spb = core.getStepsPerBeat(state.stepBeats)\n        local newLen\n        if spb then\n            newLen = math.max(0, math.min(core.maxSteps, math.floor(v) * spb))\n        else\n            newLen = math.max(0, math.min(core.maxSteps, math.floor(v)))\n        end\n        state.sequenceLength = newLen\n        state.sequenceLengths[state.activeSequenceIndex] = newLen\n        state.currentStep = math.min(math.max(1, state.currentStep), math.max(newLen, 1))\n        markSequenceDirty()\n    end\n    state.beatEditing = false\n    state.beatInputBuffer = nil\nend\n\nfunction commitSeekEdit()\n    local v = tonumber(state.seekInputBuffer)\n    if v and v >= 1 then\n        local targetStep = core.barToFirstStep(v, state.meterNumerator, state.meterDenominator, state.stepBeats)\n        local seqLen = state.sequenceLengths[state.activeSequenceIndex] or state.sequenceLength or 1\n        targetStep = math.max(1, math.min(targetStep, seqLen))\n        state.playbackPosition = targetStep\n        state.currentPlaybackStep = targetStep\n    end\n    state.seekEditing = false\n    state.seekInputBuffer = nil\nend\n\nlocal syncPitchRecognitionForSingSolfege\n\nlocal function setMasterVolume(volume, shouldPersist)\n    local clamped = math.max(0, math.min(1, tonumber(volume) or 1))\n    state.masterVolume = clamped\n    if state.audioMuted then\n        snd.setVolume(0)\n        if stopAllVoices then stopAllVoices() end\n        if stopDrone then stopDrone() end\n    else\n        snd.setVolume(clamped)\n        if state._masterVolumeInitialized and refreshDrone then refreshDrone() end\n    end\n    state._masterVolumeInitialized = true\n    if shouldPersist then\n        savePreferences()\n    end\nend\n\nfunction applyStreamAudioPreference()\n    local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if host and host.audio and host.audio.setStreamAudioEnabled then\n        return host.audio.setStreamAudioEnabled(state.streamAudioEnabled == true)\n    end\n    return false, \"unsupported\"\nend\n\nfunction setStreamAudioEnabled(enabled, shouldPersist)\n    local requested = enabled == true\n    state.streamAudioEnabled = requested\n    local ok, status = applyStreamAudioPreference()\n    if requested and ok == false then\n        state.streamAudioEnabled = false\n    end\n    if shouldPersist then\n        savePreferences()\n    end\n    return ok, status\nend\n\n_prefSavePendingAt = nil\n_PREF_SAVE_DEBOUNCE_MS = 350\n\nfunction savePreferences()\n    -- Debounce: coalesce rapid calls (e.g. holding arrow keys on BPM) into one write.\n    _prefSavePendingAt = getCurrentTimeMilliseconds() + _PREF_SAVE_DEBOUNCE_MS\nend\n\nlocal function _flushSavePreferences()\n    if _prefSavePendingAt and getCurrentTimeMilliseconds() >= _prefSavePendingAt then\n        _prefSavePendingAt = nil\n        storage.savePreferences(storageAdapter.write, preferencesFilename, state)\n    end\nend\n\nfunction loadPreferences()\n    storage.loadPreferences(storageAdapter.read, preferencesFilename, state)\n    state.singSolfegeMode = state.defaultSingSolfegeMode\n    refreshSolfegeNotes()\n    snapKeyNoteToScale()\n    core.setTimeSignature(state, state.meterNumerator, state.meterDenominator)\n    setTempo(state.defaultTempo)\n    gfx.setDarkMode(state.darkMode)\n    setMasterVolume(state.masterVolume or 1, false)\n    applyStreamAudioPreference()\n    if syncPitchRecognitionForSingSolfege then\n        syncPitchRecognitionForSingSolfege()\n    end\nend\n\nfunction resetPreferences()\n    local defaults = core.createState()\n    state.showHandsDuringPlayback = defaults.showHandsDuringPlayback\n    state.soundPreviewEnabled = defaults.soundPreviewEnabled\n    state.soundPreviewOnNavigation = defaults.soundPreviewOnNavigation\n    state.acapellaMode = defaults.acapellaMode\n    state.singSolfegeMode = defaults.singSolfegeMode\n    state.defaultSingSolfegeMode = defaults.defaultSingSolfegeMode\n    state.singSolfegeOctaveOffset = defaults.singSolfegeOctaveOffset\n    state.pitchRecognitionEnabled = defaults.pitchRecognitionEnabled\n    state.rootNote = defaults.rootNote\n    state.solfegeScale = defaults.solfegeScale\n    state.droneEnabled = defaults.droneEnabled\n    state.droneNoteSelection = defaults.droneNoteSelection\n    state.droneOctave = defaults.droneOctave\n    state.playbackStopSeconds = defaults.playbackStopSeconds\n    state.playKeyBeforeSteps = defaults.playKeyBeforeSteps\n    state.keyLeadInBeats = defaults.keyLeadInBeats\n    state.keyNote = defaults.keyNote\n    snapKeyNoteToScale()\n    state.keyOctave = defaults.keyOctave\n    state.randomizeRootPlayback = defaults.randomizeRootPlayback\n    state.randomizeOctavePlayback = defaults.randomizeOctavePlayback\n    state.hideNoteNamesDuringPlayback = defaults.hideNoteNamesDuringPlayback\n    state.earTrainingRevealAfterPlayback = defaults.earTrainingRevealAfterPlayback\n    state.hideNoteNamesDuringSing = defaults.hideNoteNamesDuringSing\n    state.showNoteNames = defaults.showNoteNames\n    state.useShapeNotes = defaults.useShapeNotes\n    state.showLyrics = defaults.showLyrics\n    state.showSolfegeLyrics = defaults.showSolfegeLyrics\n    state.hideSteps = defaults.hideSteps\n    state.showSolfegeTextInput = defaults.showSolfegeTextInput\n    state.showSolfegeButtons = defaults.showSolfegeButtons\n    state.solfegeTextMode = defaults.solfegeTextMode\n    state._lyricsForcesHideSteps = defaults._lyricsForcesHideSteps\n    state._lyricsHideStepsSaved = defaults._lyricsHideStepsSaved\n    state.showToolsRow = defaults.showToolsRow\n    state.showBarsBeatsRow = defaults.showBarsBeatsRow\n    state.showBarLines = defaults.showBarLines\n    state.showMicRow = defaults.showMicRow\n    state.darkMode = defaults.darkMode\n    state.masterVolume = defaults.masterVolume\n    state.streamAudioEnabled = defaults.streamAudioEnabled\n    setMasterVolume(state.masterVolume, false)\n    applyStreamAudioPreference()\n    gfx.setDarkMode(state.darkMode)\n    refreshSolfegeNotes()\n    state.playbackRootNote = defaults.playbackRootNote\n    state.defaultTempo = defaults.defaultTempo\n    setTempo(state.defaultTempo)\n    state.stepBeats = defaults.stepBeats\n    setStepBeats(state.stepBeats)\n    core.setTimeSignature(state, defaults.meterNumerator, defaults.meterDenominator)\n    syncPitchRecognitionForSingSolfege()\n    savePreferences()\n    schedulePlaybackStopTimer()\n    refreshDrone()\nend\n\nschedulePlaybackStopTimer = function()\n    if playbackStopTimer then\n        playbackStopTimer:remove()\n        playbackStopTimer = nil\n    end\n    if state.playbackStopSeconds > 0 then\n        playbackStopTimer = timerAdapter.newOneShot(state.playbackStopSeconds * 1000, function()\n            if state.isPlaying then\n                stopPlayback()\n            end\n        end)\n    end\nend\n\n-- Double-tap state for B button\nlocal lastBPressTime = 0\nlocal doubleTapWindow = 300 -- milliseconds\n\n-- Save/Load state\nlocal saveFilename = \"sequence.json\"\nlocal musicXMLStorageDirectory = os.getenv(\"SOLFEGE_MUSICXML_DIR\")\n    or (((package.loaded[\"onedrive_paths\"] or import \"onedrive_paths\").resolveAppDirectories() or {}).musicXMLRoot)\n    or \"../solfege_musicxml\"\nlocal autoSaveMusicXMLFilename = \"\"\n\nlocal joinPath = pm.joinPath\n\nlocal function resolveMusicXMLStoragePath(fileName)\n    local name = tostring(fileName or \"\")\n    if name:match(\"^/\") or name:match(\"^~\") or name:match(\"^%a:[/\\\\]\") then\n        return name\n    end\n    return joinPath(musicXMLStorageDirectory, name)\nend\n\nlocal basename = pm.basename\n\n-- Returns the musicxml file path within a .solfege package.\n-- Uses <pkgname>.musicxml (e.g. project-1.solfege/project-1.musicxml).\nlocal function pkgXMLFilename(pkgPath)\n    local stem = (basename(pkgPath) or \"\"):match(\"^(.+)%.solfege$\") or \"sequence\"\n    return pkgPath .. \"/\" .. stem .. \".musicxml\"\nend\n\nautoSaveMusicXMLFilename = pkgXMLFilename(resolveMusicXMLStoragePath(\"export.solfege\"))\nlocal showSaveMessage = false\nlocal saveMessageTimer = 0\nlocal showImportMessage = false\nlocal importMessageTimer = 0\nlocal importMessageText = \"✓ Imported!\"\n\n-- Sample recording state\nlocal isRecording = false\nlocal recordedSample = nil\nlocal useSampleMode = false\nlocal recordingDuration = 3.0 -- 3 seconds for longer samples\n\n-- Pitch recognition state\nlocal pitchRecognitionSampleDuration = 0.12\nlocal pitchRecognitionIntervalMs = 100\nlocal pitchRecognitionMinFrequency = 80\nlocal pitchRecognitionMaxFrequency = 1100\nlocal pitchRecognitionCorrelationThreshold = 0.28\nlocal pitchRecognitionMinRms = 0.015\nlocal pitchFeedbackCooldownMs = 300\nlocal pitchHoldTargetMs = 120  -- Hold duration before advancing\nlocal pitchHoldCloseDecayRate = 0.15  -- Moderate decay when close (don't accumulate)\nlocal pitchHoldOffDecayRate = 0.05    -- Slow decay when off to tolerate noisy pitch detection\nlocal pitchHoldSilentDecayRate = 0.2  -- Moderate-fast decay during silence\nlocal pitchHoldCloseAccumulateRate = 1.2  -- Faster accumulation when close\nlocal singSolfegeOctaveFlex = 3  -- Allow 3 octaves flexibility\nlocal singSolfegeOctavePenaltyCents = 20  -- Lower penalty for octave differences\nlocal singSolfegeAcceptOctaveMatch = true\nlocal singSolfegeAutoOctaveShiftMs = 1200\nlocal singSolfegeAutoOctaveMessageMs = 1600\nlocal micPeakDecayPerMs = 0.0012\nlocal pitchHistoryWindow = 5  -- Smaller window for faster response\nlocal pitchHistoryMaxAgeMs = 350  -- Shorter history for quicker pitch changes\nlocal pitchHistoryMaxSpreadCents = 50  -- Tighter spread threshold to reject outliers\nlocal pitchConfidenceThreshold = 0.2  -- Lower threshold to pick up singing more easily\nlocal vibratoMinSpreadCents = 30      -- Minimum spread to consider vibrato (below = stable pitch)\nlocal vibratoMaxSpreadCents = 120     -- Maximum spread for vibrato (above = true pitch change)\nlocal vibratoMinZeroCrossings = 2     -- Minimum direction reversals to classify as vibrato\nlocal vibratoDetected = false         -- Shared flag for vibrato state\nlocal expectedNoteBiasCents = 200     -- Bias smoothing within this range of expected note\nlocal expectedNoteBiasStrength = 0.3  -- How strongly to pull toward expected (0=none, 1=full snap)\nlocal singSolfegeRestAdvanceAt = nil\nlocal singSolfegeRootHoldRepeatAt = nil\nlocal singSolfegeRootHoldRepeatMs = 450\nlocal pitchRecognition = {\n    isListening = false,\n    isRecording = false,\n    lastSampleAt = 0\n}\n\n-- Check if C extension is available for pitch detection\nlocal useCPitchDetector = false\nif rawget(_G, \"pitchDetector\") and pitchDetector.isAvailable and pitchDetector.isAvailable() then\n    useCPitchDetector = true\nend\nstate.useCPitchDetector = useCPitchDetector\nlocal lastPitchFeedbackAt = 0\nlocal lastPitchFeedbackStatus = nil\nlocal pitchFeedbackDeafUntil = 0\nlocal pitchFeedbackDeafDurationMs = 500  -- Short mute after root note plays\nlocal pitchDetectorMuted = false\nlocal pitchUnmutedAt = 0\nlocal pitchGracePeriodMs = 200  -- Short grace period after unmuting\n-- Triangle wave for pitch feedback: harmonics make it easier to hear and match\nlocal pitchFeedbackSynth = snd.synth.new(snd.kWaveTriangle)\nlocal pitchHistory = {}\n\npitchFeedbackSynth:setVolume(0.3)\npitchFeedbackSynth:setADSR(0.01, 0.05, 0.35, 0.15)\nstate.pitchHoldTargetMs = pitchHoldTargetMs\nstate.singSolfegeAutoOctaveShiftMs = singSolfegeAutoOctaveShiftMs\n\nclearPitchRecognitionResults = function()\n    state.pitchDetectedNote = nil\n    state.pitchDetectedOctave = nil\n    state.pitchDetectedFrequency = nil\n    state.pitchDetectedCents = nil\n    state.pitchMatchStatus = nil\n    state.pitchExpectedNotes = nil\n    state.pitchExpectedNote = nil\n    state.pitchExpectedOctave = nil\n    state.pitchMatchCents = nil\n    state.pitchMatchOctaveOffset = nil\n    state.pitchHoldMs = 0\n    state.pitchHoldAchieved = false\n    state.pitchHoldLastAt = nil\n    state.micLevel = 0\n    state.micPeakLevel = 0\n    state.micPeakLastAt = nil\n    state.pitchExpectedStep = nil\n    state.singSolfegeStepResults = {}\n    state.singSolfegeRestRemainingMs = nil\n    state.singSolfegeAutoOctaveHoldMs = 0\n    state.singSolfegeAutoOctaveMessage = nil\n    state.singSolfegeAutoOctaveMessageUntil = nil\n    lastPitchFeedbackStatus = nil\n    pitchHistory = {}\nend\n\nsyncPitchRecognitionForSingSolfege = function()\n    state.pitchRecognitionEnabled = state.singSolfegeMode or state.micStepRecording\n    if not state.pitchRecognitionEnabled then\n        -- Don't close the audio device — SDL capture doesn't resume reliably after close/reopen on macOS.\n        -- Just clear pitch state; the device stays open and resumes when needed.\n        clearPitchRecognitionResults()\n    end\nend\n\nstopPitchRecognition = function()\n    if useCPitchDetector then\n        if pitchDetector.isListening() then\n            pitchDetector.stopListening()\n        end\n        pitchRecognition.isListening = false\n        pitchRecognition.isRecording = false\n        return\n    end\n    if pitchRecognition.isListening then\n        snd.micinput.stopListening()\n    end\n    pitchRecognition.isListening = false\n    pitchRecognition.isRecording = false\nend\n\nfunction startPitchRecognition()\n    if pitchRecognition.isListening then\n        return true\n    end\n    if useCPitchDetector then\n        local success = pitchDetector.startListening()\n        pitchRecognition.isListening = success\n        return success\n    end\n    local success = false\n    if snd.micinput and snd.micinput.startListening then\n        local listeningSuccess = snd.micinput.startListening()\n        if type(listeningSuccess) == \"boolean\" then\n            success = listeningSuccess\n        else\n            success = true\n        end\n    end\n    pitchRecognition.isListening = success\n    return pitchRecognition.isListening\nend\n\nfunction frequencyToNote(freq)\n    if not freq or freq <= 0 then\n        return nil, nil, nil\n    end\n    local baseFreq = core.noteFreqs[0]\n    if not baseFreq then\n        return nil, nil, nil\n    end\n    local semitonesFromC4 = 12 * (math.log(freq / baseFreq) / math.log(2))\n    local roundedSemitone = math.floor(semitonesFromC4 + 0.5)\n    local noteIndex = ((roundedSemitone % 12) + 12) % 12\n    local octave = 4 + math.floor(roundedSemitone / 12)\n    local targetFreq = (core.noteFreqs[noteIndex] or baseFreq) * (2 ^ (octave - 4))\n    local cents = 1200 * (math.log(freq / targetFreq) / math.log(2))\n    return noteIndex, octave, cents\nend\n\ntransposeNoteForKey = function(noteIndex, octave)\n    if noteIndex == nil or noteIndex == 13 then\n        return noteIndex, octave\n    end\n    local rootNote = state.rootNote or 0\n    if state.isPlaying and state.randomizeRootPlayback then\n        rootNote = state.playbackRootNote or rootNote\n    end\n    local keyShift = state.keyShift or 0\n    local totalShift = rootNote + keyShift\n    if totalShift == 0 then\n        return noteIndex, octave\n    end\n    local semitone = noteIndex + totalShift\n    local octaveShift = math.floor(semitone / 12)\n    local transposedNote = semitone % 12\n    local transposedOctave = math.min(7, octave + octaveShift)\n    return transposedNote, transposedOctave\nend\n\napplySingSolfegeOctaveOffset = function(octave)\n    if not state.singSolfegeMode then\n        return octave\n    end\n    local offset = state.singSolfegeOctaveOffset or 0\n    return math.max(2, math.min(7, octave + offset))\nend\n\nfunction getSingSolfegeHoldTargetMs()\n    return pitchHoldTargetMs\nend\n\nfunction getExpectedNotesForPitchMatch()\n    local stepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n    local playbackSequence = state.isPlaying and getPlaybackSequence() or state.sequence\n    local stepData = playbackSequence[stepIndex]\n    if not stepData then\n        return {}\n    end\n    local sequenceIndex = state.activeSequenceIndex or 1\n    local octaveTranspose = 0\n    if state.sequenceOctaveTranspose then\n        octaveTranspose = state.sequenceOctaveTranspose[sequenceIndex] or 0\n    end\n    local chordNotes = core.getChordNotes(stepData)\n    if #chordNotes == 0 then\n        return {}\n    end\n    local expectedNotes = {}\n    for _, noteData in ipairs(chordNotes) do\n        if noteData.note ~= nil and noteData.note ~= 13 and core.noteFreqs[noteData.note] ~= nil then\n            local transposedNote, transposedOctave = transposeNoteForKey(noteData.note, noteData.octave)\n            transposedOctave = math.max(2, math.min(7, transposedOctave + octaveTranspose))\n            transposedOctave = applySingSolfegeOctaveOffset(transposedOctave)\n            table.insert(expectedNotes, {note = transposedNote, octave = transposedOctave})\n        end\n    end\n    return expectedNotes\nend\n\nfunction buildPitchMatchCandidates(expectedNotes)\n    if not expectedNotes or #expectedNotes == 0 then\n        return {}\n    end\n    local candidates = {}\n    for _, noteData in ipairs(expectedNotes) do\n        table.insert(candidates, {\n            note = noteData.note,\n            octave = noteData.octave,\n            octaveOffset = 0\n        })\n        -- Allow singing in any octave (up to 3 octaves flex)\n        if state.singSolfegeMode then\n            local maxFlex = 3\n            for offset = 1, maxFlex do\n                local upOctave = (noteData.octave or 0) + offset\n                if upOctave <= 8 then\n                    table.insert(candidates, {\n                        note = noteData.note,\n                        octave = upOctave,\n                        octaveOffset = offset\n                    })\n                end\n                local downOctave = (noteData.octave or 0) - offset\n                if downOctave >= 1 then\n                    table.insert(candidates, {\n                        note = noteData.note,\n                        octave = downOctave,\n                        octaveOffset = -offset\n                    })\n                end\n            end\n        end\n    end\n    return candidates\nend\n\n-- Match sung pitch to expected solfege note (note name only, any octave)\nfunction matchPitchToSolfege(freq, expectedNotes)\n    if not freq or freq <= 0 or not expectedNotes or #expectedNotes == 0 then\n        return nil, nil, nil\n    end\n\n    -- Get the detected note (0-11) from frequency\n    local detectedNote, detectedOctave, detectedCents = frequencyToNote(freq)\n    if not detectedNote then\n        return nil, nil, nil\n    end\n\n    -- Check if detected note matches expected solfege note (ignoring octave)\n    local expectedNote = expectedNotes[1].note\n    local expectedOctave = expectedNotes[1].octave\n\n    -- Calculate semitone difference (accounting for octave)\n    local noteDiff = detectedNote - expectedNote\n    -- Wrap to nearest match (-6 to +6 semitones)\n    if noteDiff > 6 then noteDiff = noteDiff - 12 end\n    if noteDiff < -6 then noteDiff = noteDiff + 12 end\n\n    -- Calculate octave offset\n    local octaveOffset = detectedOctave - expectedOctave\n\n    -- Convert semitone difference to cents and add the fine cents\n    local totalCents = (noteDiff * 100) + detectedCents\n\n    return totalCents, octaveOffset, {\n        note = expectedNote,\n        octave = detectedOctave,\n        octaveOffset = octaveOffset\n    }\nend\n\nfunction updatePitchExpectedNotes(expectedNotes)\n    if expectedNotes and #expectedNotes > 0 then\n        state.pitchExpectedNotes = expectedNotes\n        -- Tell the C pitch detector which solfege note we expect\n        -- This helps it use vowel/formant analysis to improve accuracy\n        if useCPitchDetector and pitchDetector.setExpectedNote then\n            local note = expectedNotes[1].note\n            -- Note is 0-12 (Do to Do'), -1 for rest\n            if note and note ~= 13 then\n                pitchDetector.setExpectedNote(note)\n            else\n                pitchDetector.setExpectedNote(-1)\n            end\n        end\n        -- Send expected frequency to C for multi-candidate pitch bias\n        -- This helps the detector choose the right octave when multiple\n        -- YIN minima are present (very common with singing voice)\n        if useCPitchDetector and pitchDetector.setExpectedFrequency then\n            local note = expectedNotes[1].note\n            local octave = expectedNotes[1].octave or 4\n            if note and note ~= 13 and core.noteFreqs[note] then\n                local expectedFreq = core.noteFreqs[note] * (2 ^ (octave - 4))\n                pitchDetector.setExpectedFrequency(expectedFreq)\n            else\n                pitchDetector.setExpectedFrequency(0)\n            end\n        end\n    else\n        state.pitchExpectedNotes = nil\n        -- Clear expected note when no notes expected\n        if useCPitchDetector and pitchDetector.setExpectedNote then\n            pitchDetector.setExpectedNote(-1)\n        end\n        if useCPitchDetector and pitchDetector.setExpectedFrequency then\n            pitchDetector.setExpectedFrequency(0)\n        end\n    end\nend\n\nfunction setPitchRecognitionUnsupported()\n    local expectedNotes = getExpectedNotesForPitchMatch()\n    updatePitchExpectedNotes(expectedNotes)\n    if expectedNotes and expectedNotes[1] then\n        state.pitchExpectedNote = expectedNotes[1].note\n        state.pitchExpectedOctave = expectedNotes[1].octave\n    else\n        state.pitchExpectedNote = nil\n        state.pitchExpectedOctave = nil\n    end\n    state.pitchMatchStatus = \"unsupported\"\n    state.pitchDetectedFrequency = nil\n    state.pitchDetectedNote = nil\n    state.pitchDetectedOctave = nil\n    state.pitchDetectedCents = nil\n    state.pitchMatchCents = nil\n    state.pitchMatchOctaveOffset = nil\n    state.pitchHoldMs = 0\n    state.pitchHoldAchieved = false\n    state.pitchHoldLastAt = getCurrentTimeMilliseconds()\n    state.singSolfegeAutoOctaveHoldMs = 0\n    state.micLevel = 0\nend\n\nfunction setSingSolfegeStepResult(stepIndex, result)\n    if not stepIndex or not result then\n        return\n    end\n    if not state.singSolfegeStepResults then\n        state.singSolfegeStepResults = {}\n    end\n    local current = state.singSolfegeStepResults[stepIndex]\n    if current == result then\n        return\n    end\n    local priority = {wrong = 1, close = 2, correct = 3}\n    if current and priority[current] and priority[result] and priority[current] >= priority[result] then\n        return\n    end\n    state.singSolfegeStepResults[stepIndex] = result\nend\n\nfunction matchDetectedPitch(freq, noteIndex, octave, cents)\n    pitchHoldTargetMs = getSingSolfegeHoldTargetMs()\n    state.pitchHoldTargetMs = pitchHoldTargetMs\n    local currentStepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n    if state.pitchExpectedStep ~= currentStepIndex then\n        state.pitchHoldMs = 0\n        state.pitchHoldAchieved = false\n        state.pitchHoldLastAt = getCurrentTimeMilliseconds()\n        state.pitchExpectedStep = currentStepIndex\n        state.singSolfegeAutoOctaveHoldMs = 0\n    end\n    local expectedNotes = getExpectedNotesForPitchMatch()\n    updatePitchExpectedNotes(expectedNotes)\n    if expectedNotes and expectedNotes[1] then\n        state.pitchExpectedNote = expectedNotes[1].note\n        state.pitchExpectedOctave = expectedNotes[1].octave\n    else\n        state.pitchExpectedNote = nil\n        state.pitchExpectedOctave = nil\n    end\n    state.pitchMatchCents = nil\n    state.pitchMatchOctaveOffset = nil\n    if state.micStepRecording and not state.singSolfegeMode then\n        -- Edit mode mic recording: any sustained pitch is valid, skip expected-note matching\n        state.pitchMatchStatus = \"match\"\n        state.pitchMatchCents = 0\n        state.pitchMatchOctaveOffset = 0\n    elseif #expectedNotes == 0 then\n        state.pitchMatchStatus = nil\n        state.pitchHoldMs = 0\n        state.pitchHoldAchieved = false\n        state.pitchHoldLastAt = nil\n        return\n    else\n        -- Use simplified solfege matching - checks if sung note matches expected solfege syllable\n        local totalCents, octaveOffset, matchData = matchPitchToSolfege(freq, expectedNotes)\n\n        if matchData then\n            state.pitchExpectedNote = matchData.note\n            state.pitchExpectedOctave = matchData.octave\n            state.pitchMatchCents = totalCents\n            state.pitchMatchOctaveOffset = octaveOffset or 0\n            local absCents = math.abs(totalCents or 0)\n\n            -- Check if the C extension detected matching solfege vowel formants\n            -- When the singer is using the correct solfege syllable, we can widen\n            -- tolerance slightly since we have additional confirmation\n            local syllableBoost = 0\n            state.syllableMatchBoost = 0\n            if useCPitchDetector and pitchDetector.getSyllableMatchInfo then\n                local detVowel, expVowel, boost = pitchDetector.getSyllableMatchInfo()\n                if boost and boost > 0.1 then\n                    -- Singer's vowel formants match the expected solfege syllable\n                    -- Widen match threshold by up to 15 cents (conservative)\n                    syllableBoost = math.floor(boost * 60)  -- 0.25 * 60 = 15 cents max\n                    state.syllableMatchBoost = syllableBoost\n                end\n            end\n\n            -- Match within half a semitone, close within one semitone\n            local matchThreshold = 50 + syllableBoost\n            local closeThreshold = 100 + syllableBoost\n\n            if absCents <= matchThreshold then\n                -- Singing the correct solfege syllable with good pitch\n                state.pitchMatchStatus = \"match\"\n            elseif absCents <= closeThreshold then\n                -- Close - within one semitone (might be slightly off)\n                state.pitchMatchStatus = \"close\"\n            else\n                local now = getCurrentTimeMilliseconds()\n                if pitchUnmutedAt > 0 and (now - pitchUnmutedAt) < pitchGracePeriodMs then\n                    state.pitchMatchStatus = \"silent\"\n                else\n                    state.pitchMatchStatus = \"off\"\n                end\n            end\n        else\n            state.pitchMatchStatus = nil\n            state.pitchMatchOctaveOffset = nil\n        end\n        if state.singSolfegeMode and state.isPlaying and state.singSolfegeStepResults then\n            if state.pitchMatchStatus == \"match\" then\n                if (state.pitchMatchOctaveOffset or 0) ~= 0 and singSolfegeAcceptOctaveMatch then\n                    setSingSolfegeStepResult(currentStepIndex, \"close\")\n                else\n                    setSingSolfegeStepResult(currentStepIndex, \"correct\")\n                end\n            elseif state.pitchMatchStatus == \"close\" then\n                setSingSolfegeStepResult(currentStepIndex, \"close\")\n            elseif state.pitchMatchStatus == \"off\" then\n                setSingSolfegeStepResult(currentStepIndex, \"wrong\")\n            end\n        end\n    end\n    local now = getCurrentTimeMilliseconds()\n    local lastAt = state.pitchHoldLastAt or now\n    local delta = math.max(0, now - lastAt)\n    state.pitchHoldLastAt = now\n    if state.singSolfegeMode and state.isPlaying then\n        local octaveOffset = state.pitchMatchOctaveOffset or 0\n        if state.pitchMatchStatus == \"match\" and octaveOffset ~= 0 then\n            local autoHold = (state.singSolfegeAutoOctaveHoldMs or 0) + delta\n            state.singSolfegeAutoOctaveHoldMs = autoHold\n            if autoHold >= singSolfegeAutoOctaveShiftMs then\n                local expectedOctave = state.pitchExpectedOctave or 4\n                local shift = octaveOffset\n                local shiftedOctave = expectedOctave + shift\n                if shiftedOctave < 2 then\n                    shift = 2 - expectedOctave\n                elseif shiftedOctave > 7 then\n                    shift = 7 - expectedOctave\n                end\n                if shift ~= 0 then\n                    state.singSolfegeOctaveOffset = (state.singSolfegeOctaveOffset or 0) + shift\n                    local direction = shift > 0 and \"up\" or \"down\"\n                    local absShift = math.abs(shift)\n                    local octaveLabel = absShift == 1 and \"octave\" or \"octaves\"\n                    state.singSolfegeAutoOctaveMessage = string.format(\"Shifted target %s %d %s\", direction, absShift, octaveLabel)\n                    state.singSolfegeAutoOctaveMessageUntil = now + singSolfegeAutoOctaveMessageMs\n                    local updatedExpected = getExpectedNotesForPitchMatch()\n                    updatePitchExpectedNotes(updatedExpected)\n                    if updatedExpected and updatedExpected[1] then\n                        state.pitchExpectedNote = updatedExpected[1].note\n                        state.pitchExpectedOctave = updatedExpected[1].octave\n                    end\n                end\n                state.singSolfegeAutoOctaveHoldMs = 0\n            end\n        else\n            state.singSolfegeAutoOctaveHoldMs = 0\n        end\n    else\n        state.singSolfegeAutoOctaveHoldMs = 0\n    end\n    -- Use confidence and syllable match to modulate hold accumulation rate\n    local confidence = state.pitchConfidence or 0\n    -- Confidence must be significant to accumulate hold (0.0 at low conf, 1.0 at high)\n    -- In Edit mode mic recording, bypass confidence requirement since any sustained pitch is valid\n    local confidenceMultiplier\n    if state.micStepRecording and not state.singSolfegeMode then\n        confidenceMultiplier = 1.0\n    else\n        confidenceMultiplier = math.max(0, (confidence - 0.3) / 0.7)  -- 0.0 below 0.3, scales to 1.0 at 1.0\n    end\n\n    -- When the singer uses the correct solfege syllable, accumulate hold faster\n    -- This rewards singing with proper syllables rather than just humming the pitch\n    local syllableAcceleration = 1.0\n    if (state.syllableMatchBoost or 0) > 0 then\n        syllableAcceleration = 1.0 + (state.syllableMatchBoost / 40)  -- Up to 1.5x faster\n    end\n\n    local micLvl = state.micLevel or 0\n    local isSinging = micLvl > 0.3 and (state.pitchMatchStatus == \"match\" or state.pitchMatchStatus == \"close\")\n    if isSinging then\n        -- Accumulate hold only when pitch is correct or close\n        local rate = 1.0\n        if state.pitchMatchStatus == \"close\" then\n            rate = 0.5\n        end\n        local effectiveDelta = delta * rate * confidenceMultiplier * syllableAcceleration\n        local nextHold = (state.pitchHoldMs or 0) + effectiveDelta\n        state.pitchHoldMs = math.min(pitchHoldTargetMs, nextHold)\n    else\n        -- Decay when not singing\n        local currentHold = state.pitchHoldMs or 0\n        local decay = delta * pitchHoldSilentDecayRate\n        state.pitchHoldMs = math.max(0, currentHold - decay)\n    end\n    state.pitchHoldAchieved = (state.pitchHoldMs or 0) >= pitchHoldTargetMs\nend\n\nfunction detectPitchFromSamples(samples, sampleRate)\n    if not samples or #samples < 1 or not sampleRate then\n        return nil\n    end\n    local count = #samples\n    local rmsSum = 0\n    local mean = 0\n    for i = 1, count do\n        local sample = samples[i]\n        rmsSum = rmsSum + sample * sample\n        mean = mean + sample\n    end\n    local rms = math.sqrt(rmsSum / count)\n    if rms < 0.01 then\n        return nil\n    end\n    mean = mean / count\n    local centered = {}\n    for i = 1, count do\n        centered[i] = samples[i] - mean\n    end\n    local minLag = math.floor(sampleRate / pitchRecognitionMaxFrequency)\n    local maxLag = math.floor(sampleRate / pitchRecognitionMinFrequency)\n    minLag = math.max(1, minLag)\n    maxLag = math.min(maxLag, count - 1)\n\n    -- Helper: compute normalized cross-correlation at a given lag\n    local function correlationAt(lag)\n        if lag < 1 or lag >= count then return 0 end\n        local sum = 0\n        local sumSqA = 0\n        local sumSqB = 0\n        local limit = count - lag\n        for i = 1, limit do\n            local a = centered[i]\n            local b = centered[i + lag]\n            sum = sum + (a * b)\n            sumSqA = sumSqA + (a * a)\n            sumSqB = sumSqB + (b * b)\n        end\n        if sumSqA > 0 and sumSqB > 0 then\n            return sum / math.sqrt(sumSqA * sumSqB)\n        end\n        return 0\n    end\n\n    -- Pass 1: Coarse search - scan every 2nd lag to find candidate region\n    local coarseBestLag = nil\n    local coarseBestScore = 0\n    local coarseStep = 2\n    for lag = minLag, maxLag, coarseStep do\n        local score = correlationAt(lag)\n        if score > coarseBestScore then\n            coarseBestScore = score\n            coarseBestLag = lag\n        end\n    end\n\n    if not coarseBestLag or coarseBestScore < pitchRecognitionCorrelationThreshold * 0.8 then\n        return nil\n    end\n\n    -- Pass 2: Fine search - scan every lag in a narrow window around the coarse peak\n    local fineStart = math.max(minLag, coarseBestLag - coarseStep)\n    local fineEnd = math.min(maxLag, coarseBestLag + coarseStep)\n    local bestLag = coarseBestLag\n    local bestScore = coarseBestScore\n    for lag = fineStart, fineEnd do\n        local score = correlationAt(lag)\n        if score > bestScore then\n            bestScore = score\n            bestLag = lag\n        end\n    end\n\n    if bestScore < pitchRecognitionCorrelationThreshold then\n        return nil\n    end\n\n    -- Parabolic interpolation for sub-sample accuracy\n    -- Fit a parabola through (bestLag-1, bestLag, bestLag+1) and find the true peak\n    local refinedLag = bestLag\n    if bestLag > minLag and bestLag < maxLag then\n        local scorePrev = correlationAt(bestLag - 1)\n        local scoreNext = correlationAt(bestLag + 1)\n        local denom = 2 * (2 * bestScore - scorePrev - scoreNext)\n        if math.abs(denom) > 1e-10 then\n            local delta = (scorePrev - scoreNext) / denom\n            -- Clamp interpolation to avoid wild extrapolation\n            if math.abs(delta) < 1 then\n                refinedLag = bestLag + delta\n            end\n        end\n    end\n\n    local detectedFreq = sampleRate / refinedLag\n\n    -- Harmonic check: verify the detected frequency isn't an octave error\n    -- by checking if a stronger peak exists at 2x the lag (octave below)\n    local doubleLag = math.floor(refinedLag * 2 + 0.5)\n    if doubleLag <= maxLag then\n        local doubleScore = correlationAt(doubleLag)\n        -- If the correlation at double-lag (fundamental) is nearly as strong,\n        -- we're likely detecting a harmonic - use the lower frequency\n        if doubleScore > bestScore * 0.85 then\n            -- Refine the double-lag peak too\n            local dblBest = doubleLag\n            local dblBestScore = doubleScore\n            for lag = math.max(minLag, doubleLag - 2), math.min(maxLag, doubleLag + 2) do\n                local sc = correlationAt(lag)\n                if sc > dblBestScore then\n                    dblBestScore = sc\n                    dblBest = lag\n                end\n            end\n            -- Parabolic interpolation on the double-lag peak\n            local dblRefined = dblBest\n            if dblBest > minLag and dblBest < maxLag then\n                local sp = correlationAt(dblBest - 1)\n                local sn = correlationAt(dblBest + 1)\n                local d = 2 * (2 * dblBestScore - sp - sn)\n                if math.abs(d) > 1e-10 then\n                    local dd = (sp - sn) / d\n                    if math.abs(dd) < 1 then\n                        dblRefined = dblBest + dd\n                    end\n                end\n            end\n            detectedFreq = sampleRate / dblRefined\n        end\n    end\n\n    -- Expected-octave preference: when we know the expected note, prefer the\n    -- octave variant that is closest to it (with a relaxed correlation threshold)\n    local expectedNote = state.pitchExpectedNote\n    local expectedOctave = state.pitchExpectedOctave\n    if expectedNote ~= nil and expectedNote ~= 13 and core.noteFreqs[expectedNote] then\n        local expectedFreq = core.noteFreqs[expectedNote] * (2 ^ ((expectedOctave or 4) - 4))\n        local log2 = math.log(2)\n        local detectedCentsDiff = math.abs(1200 * (math.log(detectedFreq / expectedFreq) / log2))\n\n        -- Check one octave down (double the lag)\n        local octaveDownFreq = detectedFreq / 2\n        local downCentsDiff = math.abs(1200 * (math.log(octaveDownFreq / expectedFreq) / log2))\n        if downCentsDiff < detectedCentsDiff and downCentsDiff < 150 then\n            local downLag = math.floor(refinedLag * 2 + 0.5)\n            if downLag <= maxLag then\n                local downScore = correlationAt(downLag)\n                if downScore > bestScore * 0.70 then\n                    detectedFreq = sampleRate / downLag\n                end\n            end\n        end\n\n        -- Check one octave up (half the lag)\n        local octaveUpFreq = detectedFreq * 2\n        local upCentsDiff = math.abs(1200 * (math.log(octaveUpFreq / expectedFreq) / log2))\n        if upCentsDiff < detectedCentsDiff and upCentsDiff < 150 then\n            local upLag = math.floor(refinedLag / 2 + 0.5)\n            if upLag >= minLag then\n                local upScore = correlationAt(upLag)\n                if upScore > bestScore * 0.70 then\n                    detectedFreq = sampleRate / upLag\n                end\n            end\n        end\n    end\n\n    return detectedFreq\nend\n\nfunction getSampleRms(samples)\n    if not samples or #samples < 1 then\n        return 0\n    end\n    local sum = 0\n    for i = 1, #samples do\n        sum = sum + samples[i] * samples[i]\n    end\n    return math.sqrt(sum / #samples)\nend\n\nfunction updateMicPeak(level)\n    local now = getCurrentTimeMilliseconds()\n    local lastAt = state.micPeakLastAt or now\n    local delta = math.max(0, now - lastAt)\n    local decayedPeak = math.max(0, (state.micPeakLevel or 0) - (delta * micPeakDecayPerMs))\n    local nextPeak = math.max(decayedPeak, level or 0)\n    state.micPeakLevel = math.min(1, nextPeak)\n    state.micPeakLastAt = now\nend\n\nfunction clearPitchHistory()\n    pitchHistory = {}\nend\n\nfunction addPitchHistory(freq)\n    local now = getCurrentTimeMilliseconds()\n    local confidence = state.pitchConfidence or 0.5\n    table.insert(pitchHistory, {freq = freq, at = now, confidence = confidence})\n    while #pitchHistory > pitchHistoryWindow do\n        table.remove(pitchHistory, 1)\n    end\n    while #pitchHistory > 0 and (now - pitchHistory[1].at) > pitchHistoryMaxAgeMs do\n        table.remove(pitchHistory, 1)\n    end\nend\n\nfunction getSmoothedFrequency(freq)\n    if not freq or freq <= 0 then\n        state._smoothedFreq = nil\n        return freq\n    end\n\n    local prev = state._smoothedFreq\n    if not prev or prev <= 0 then\n        state._smoothedFreq = freq\n        return freq\n    end\n\n    -- Check how far the new frequency is from the smoothed one\n    local ratio = freq > prev and freq / prev or prev / freq\n    local centsDiff = 1200 * (math.log(ratio) / math.log(2))\n\n    if centsDiff > 80 then\n        -- Large jump (> ~3/4 semitone): trust the new reading immediately\n        -- This helps snap to new notes faster when the singer changes pitch\n        state._smoothedFreq = freq\n    else\n        -- Small deviation: light smoothing (90% new, 10% old)\n        -- C side already does heavy filtering so keep Lua-side minimal\n        state._smoothedFreq = freq * 0.9 + prev * 0.1\n    end\n    return state._smoothedFreq\nend\n\nfunction analyzePitchSample(sample)\n    if not sample then\n        return\n    end\n    if sample.decompress then\n        sample:decompress()\n    end\n    local sampleRate = sample.getSampleRate and sample:getSampleRate() or 44100\n    local totalFrames = math.floor(sampleRate * pitchRecognitionSampleDuration)\n    local startFrame = math.floor(sampleRate * 0.02)\n    local sampleCount = math.max(1, totalFrames - startFrame)\n    if not sample.getSamples then\n        setPitchRecognitionUnsupported()\n        return\n    end\n    local ok, samples = pcall(function()\n        return sample:getSamples(startFrame, sampleCount)\n    end)\n    if not ok or type(samples) ~= \"table\" or #samples == 0 then\n        return\n    end\n    local rms = getSampleRms(samples)\n    state.micLevel = math.min(1, rms * 5)\n    updateMicPeak(state.micLevel)\n    local function applyNoPitchStatus(status)\n        clearPitchHistory()\n        pitchHoldTargetMs = getSingSolfegeHoldTargetMs()\n        state.pitchHoldTargetMs = pitchHoldTargetMs\n        local expectedNotes = getExpectedNotesForPitchMatch()\n        updatePitchExpectedNotes(expectedNotes)\n        if expectedNotes and expectedNotes[1] then\n            state.pitchExpectedNote = expectedNotes[1].note\n            state.pitchExpectedOctave = expectedNotes[1].octave\n        else\n            state.pitchExpectedNote = nil\n            state.pitchExpectedOctave = nil\n        end\n        state.pitchMatchStatus = status\n        state.pitchDetectedFrequency = nil\n        state.pitchDetectedNote = nil\n        state.pitchDetectedOctave = nil\n        state.pitchDetectedCents = nil\n        state.pitchMatchCents = nil\n        state.pitchMatchOctaveOffset = nil\n        state.singSolfegeAutoOctaveHoldMs = 0\n        local now = getCurrentTimeMilliseconds()\n        local currentStepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n        if state.pitchExpectedStep ~= currentStepIndex then\n            state.pitchHoldMs = 0\n            state.pitchHoldAchieved = false\n            state.pitchHoldLastAt = now\n            state.pitchExpectedStep = currentStepIndex\n        else\n            local lastAt = state.pitchHoldLastAt or now\n            local delta = math.max(0, now - lastAt)\n            state.pitchHoldLastAt = now\n            local currentHold = state.pitchHoldMs or 0\n            local decay = delta * pitchHoldSilentDecayRate\n            state.pitchHoldMs = math.max(0, currentHold - decay)\n            state.pitchHoldAchieved = (state.pitchHoldMs or 0) >= pitchHoldTargetMs\n        end\n        state.micLevel = math.min(1, rms * 5)\n        updateMicPeak(state.micLevel)\n    end\n\n    if rms < pitchRecognitionMinRms then\n        applyNoPitchStatus(\"quiet\")\n        return\n    end\n    local freq = detectPitchFromSamples(samples, sampleRate)\n    if not freq then\n        applyNoPitchStatus(\"silent\")\n        return\n    end\n    freq = getSmoothedFrequency(freq)\n    local detectedNote, detectedOctave, cents = frequencyToNote(freq)\n    state.pitchDetectedFrequency = freq\n    state.pitchDetectedNote = detectedNote\n    state.pitchDetectedOctave = detectedOctave\n    state.pitchDetectedCents = cents\n    matchDetectedPitch(freq, detectedNote, detectedOctave, cents)\nend\n\n-- Update pitch recognition using C extension\nfunction updatePitchRecognitionC()\n    if showRecordingScreen or isRecording then\n        stopPitchRecognition()\n        return\n    end\n    -- Keep audio device open — closing/reopening SDL capture on macOS stops audio callbacks.\n    -- Just skip processing (without closing the device) when not in a mode that needs pitch input.\n    if not state.singSolfegeMode and not state.micStepRecording then\n        clearPitchRecognitionResults()\n        return\n    end\n    if not startPitchRecognition() then\n        setPitchRecognitionUnsupported()\n        return\n    end\n    local now = getCurrentTimeMilliseconds()\n    -- Unmute mic after deaf period or immediately if not playing\n    if pitchDetectorMuted then\n        if not state.isPlaying or now >= pitchFeedbackDeafUntil then\n            if pitchDetector.unmute then\n                pitchDetector.unmute()\n            end\n            pitchDetectorMuted = false\n            if state.isPlaying then\n                pitchUnmutedAt = getCurrentTimeMilliseconds()\n            end\n        end\n    end\n    -- Tell C extension which solfege syllable we expect (for vowel formant matching)\n    -- This helps disambiguate pitch when the singer uses the correct syllable\n    if pitchDetector.setExpectedSyllable then\n        local expectedNotes = getExpectedNotesForPitchMatch()\n        if expectedNotes and expectedNotes[1] and expectedNotes[1].note ~= nil and expectedNotes[1].note ~= 13 then\n            pitchDetector.setExpectedSyllable(expectedNotes[1].note)\n        else\n            pitchDetector.setExpectedSyllable(-1)\n        end\n    end\n    -- Always update mic level from C extension (for audio meter display)\n    -- The C extension now returns frequency, micLevel, and confidence\n    local freq, micLevel, confidence = pitchDetector.update()\n    local rawLevel = micLevel or 0\n    -- state.micLevel: lightly smoothed, used for pitch logic (isSinging threshold)\n    local prevLevel = state.micLevel or 0\n    state.micLevel = prevLevel + 0.3 * (rawLevel - prevLevel)\n    -- state.micLevelDisplay: noise-gated + rate-limited, used only for the meter bar\n    -- Rate-limiting guarantees perfectly smooth linear motion regardless of input spikes\n    local gated = rawLevel < 0.10 and 0 or rawLevel\n    local prevDisplay = state.micLevelDisplay or 0\n    local maxRise = 0.018  -- max increase per frame (~0.3s to full at 60fps)\n    local maxFall = 0.008  -- max decrease per frame (~0.7s to empty)\n    local diff = gated - prevDisplay\n    diff = math.max(-maxFall, math.min(maxRise, diff))\n    state.micLevelDisplay = math.max(0, math.min(1, prevDisplay + diff))\n    state.pitchConfidence = confidence or 0\n    updateMicPeak(state.micLevel)\n    -- Skip pitch detection during deaf period (only when playing, since speaker is active)\n    if state.isPlaying and now < pitchFeedbackDeafUntil then\n        clearPitchHistory()\n        state.pitchMatchStatus = \"silent\"\n        state.pitchDetectedFrequency = nil\n        return\n    end\n    -- Skip pitch processing during grace period after unmuting (only when playing)\n    if state.isPlaying and pitchUnmutedAt > 0 and (now - pitchUnmutedAt) < pitchGracePeriodMs then\n        state.pitchMatchStatus = \"silent\"\n        state.pitchDetectedFrequency = nil\n        state.pitchDetectedNote = nil\n        state.pitchDetectedOctave = nil\n        clearPitchHistory()\n        return\n    end\n\n    -- Require minimum mic level to consider pitch valid\n    if (micLevel or 0) < 0.02 then\n        freq = 0\n    end\n\n    if not freq or freq <= 0 then\n        clearPitchHistory()\n        -- Silent or no pitch detected\n        pitchHoldTargetMs = getSingSolfegeHoldTargetMs()\n        state.pitchHoldTargetMs = pitchHoldTargetMs\n        local expectedNotes = getExpectedNotesForPitchMatch()\n        updatePitchExpectedNotes(expectedNotes)\n        if expectedNotes and expectedNotes[1] then\n            state.pitchExpectedNote = expectedNotes[1].note\n            state.pitchExpectedOctave = expectedNotes[1].octave\n        else\n            state.pitchExpectedNote = nil\n            state.pitchExpectedOctave = nil\n        end\n        state.pitchMatchStatus = \"silent\"\n        state.pitchDetectedFrequency = nil\n        state.pitchDetectedNote = nil\n        state.pitchDetectedOctave = nil\n        state.pitchDetectedCents = nil\n        state.pitchMatchCents = nil\n        state.pitchMatchOctaveOffset = nil\n        local now = getCurrentTimeMilliseconds()\n        local currentStepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n        if state.pitchExpectedStep ~= currentStepIndex then\n            state.pitchHoldMs = 0\n            state.pitchHoldAchieved = false\n            state.pitchHoldLastAt = now\n            state.pitchExpectedStep = currentStepIndex\n        else\n            local lastAt = state.pitchHoldLastAt or now\n            local delta = math.max(0, now - lastAt)\n            state.pitchHoldLastAt = now\n            local currentHold = state.pitchHoldMs or 0\n            local decay = delta * pitchHoldSilentDecayRate\n            state.pitchHoldMs = math.max(0, currentHold - decay)\n            state.pitchHoldAchieved = (state.pitchHoldMs or 0) >= pitchHoldTargetMs\n        end\n        return\n    end\n\n    -- Process detected pitch\n    freq = getSmoothedFrequency(freq)\n    local detectedNote, detectedOctave, cents = frequencyToNote(freq)\n    state.pitchDetectedFrequency = freq\n    state.pitchDetectedNote = detectedNote\n    state.pitchDetectedOctave = detectedOctave\n    state.pitchDetectedCents = cents\n\n    -- Store syllable detection info for UI feedback\n    if pitchDetector.getDetectedSyllable then\n        local syllIdx, syllConf = pitchDetector.getDetectedSyllable()\n        state.detectedSyllable = (syllIdx and syllIdx >= 0) and math.floor(syllIdx) or nil\n        state.syllableConfidence = syllConf or 0\n    end\n\n    matchDetectedPitch(freq, detectedNote, detectedOctave, cents)\nend\n\n-- Update pitch recognition using Lua/micinput (for simulator)\nfunction updatePitchRecognitionLua()\n    if showRecordingScreen or isRecording then\n        stopPitchRecognition()\n        return\n    end\n    if not startPitchRecognition() then\n        setPitchRecognitionUnsupported()\n        return\n    end\n    if not snd.micinput or not snd.micinput.recordToSample then\n        setPitchRecognitionUnsupported()\n        return\n    end\n    if pitchRecognition.isRecording then\n        return\n    end\n    local now = getCurrentTimeMilliseconds()\n    if now < pitchFeedbackDeafUntil then\n        return\n    end\n    if now - pitchRecognition.lastSampleAt < pitchRecognitionIntervalMs then\n        return\n    end\n    pitchRecognition.lastSampleAt = now\n    local buffer = snd.sample.new(pitchRecognitionSampleDuration)\n    if not buffer then\n        setPitchRecognitionUnsupported()\n        return\n    end\n    pitchRecognition.isRecording = true\n    local recordSuccess = snd.micinput.recordToSample(buffer, function(sample)\n        pitchRecognition.isRecording = false\n        analyzePitchSample(sample)\n    end)\n    if type(recordSuccess) == \"boolean\" and not recordSuccess then\n        pitchRecognition.isRecording = false\n        setPitchRecognitionUnsupported()\n    end\nend\n\nfunction updatePitchRecognition()\n    if useCPitchDetector then\n        updatePitchRecognitionC()\n    else\n        updatePitchRecognitionLua()\n    end\nend\n\nfunction syncPitchTargetsForSingSolfege()\n    if not state.singSolfegeMode then\n        return\n    end\n    local expectedNotes = getExpectedNotesForPitchMatch()\n    updatePitchExpectedNotes(expectedNotes)\n    if expectedNotes and #expectedNotes > 0 then\n        if state.pitchMatchStatus == nil or state.pitchMatchStatus == \"silent\" or state.pitchMatchStatus == \"unsupported\" then\n            state.pitchExpectedNote = expectedNotes[1].note\n            state.pitchExpectedOctave = expectedNotes[1].octave\n        end\n    else\n        state.pitchExpectedNote = nil\n        state.pitchExpectedOctave = nil\n    end\nend\nlocal showRecordingScreen = false\nshowWelcomeScreen = false\nwelcomeRecentFiles = {}\nlocal recordingStartTime = 0\nfunction toggleSoundMode()\n    if recordedSample then\n        useSampleMode = not useSampleMode\n        local mode = useSampleMode and \"Sample\" or \"Synth\"\n        print(\"Switched to \" .. mode .. \" mode\")\n    else\n        print(\"No sample recorded yet!\")\n    end\nend\n\n-- Synth with a breathy flute lead for clear solfege playback.\nlocal synth = snd.synth.new(snd.kWaveFlute)\nlocal synth2 = snd.synth.new(snd.kWaveSine) -- Sine layer for smooth fundamental\nlocal layerSynth = snd.synth.new(snd.kWaveFlute)\nlocal layerSynth2 = snd.synth.new(snd.kWaveSine) -- Layered sequence second sine layer\nlocal droneSynth = snd.synth.new(snd.kWaveWarm) -- Drone: warm harmonics, no vibrato\n\nfunction configureVoice(voice)\n    voice.synth:setVolume(0.74)\n    voice.synth:setADSR(0.045, 0.12, 0.78, 0.34)\n    -- Sine layer: on SDL, two separate mixer channels cause a double-hit artifact,\n    -- so disable it; on Playdate, both layers are mixed internally without this issue.\n    local synth2Vol = isRunningOnMacDesktop() and 0 or 0.18\n    voice.synth2:setVolume(synth2Vol)\n    voice.synth2:setADSR(0.05, 0.12, 0.52, 0.34)\nend\n\nfunction configureSynth(synth, volume, a, d, s, r)\n    synth:setVolume(volume)\n    synth:setADSR(a, d, s, r)\nend\n\n-- Drone voice (subtle background, triangle for warmth)\ndroneSynth:setVolume(0.2)\ndroneSynth:setADSR(0.05, 0.2, 0.8, 0.4)\n\n-- sequence table is already nil by default; no explicit init needed\n\n-- Sample player for recorded samples (will be created when we have a sample)\nlocal samplePlayer = nil\n\nlocal primaryVoice = {\n    synth = synth,\n    synth2 = synth2,\n    sampleSynth = nil,\n    chordSynth1 = snd.synth.new(snd.kWaveFlute),\n    chordSynth1b = snd.synth.new(snd.kWaveSine),\n    chordSynth2 = snd.synth.new(snd.kWaveFlute),\n    chordSynth2b = snd.synth.new(snd.kWaveSine)\n}\nlocal layeredVoice = {\n    synth = layerSynth,\n    synth2 = layerSynth2,\n    sampleSynth = nil,\n    chordSynth1 = snd.synth.new(snd.kWaveFlute),\n    chordSynth1b = snd.synth.new(snd.kWaveSine),\n    chordSynth2 = snd.synth.new(snd.kWaveFlute),\n    chordSynth2b = snd.synth.new(snd.kWaveSine)\n}\nconfigureVoice(primaryVoice)\nconfigureVoice(layeredVoice)\n-- Chord synths use the same flute character at lower gain so stacks stay smooth.\nconfigureSynth(primaryVoice.chordSynth1, 0.22, 0.05, 0.14, 0.68, 0.34)\nconfigureSynth(primaryVoice.chordSynth1b, isRunningOnMacDesktop() and 0 or 0.12, 0.055, 0.14, 0.48, 0.36)\nconfigureSynth(primaryVoice.chordSynth2, 0.22, 0.05, 0.14, 0.68, 0.34)\nconfigureSynth(primaryVoice.chordSynth2b, isRunningOnMacDesktop() and 0 or 0.12, 0.055, 0.14, 0.48, 0.36)\nconfigureSynth(layeredVoice.chordSynth1, 0.22, 0.05, 0.14, 0.68, 0.34)\nconfigureSynth(layeredVoice.chordSynth1b, isRunningOnMacDesktop() and 0 or 0.12, 0.055, 0.14, 0.48, 0.36)\nconfigureSynth(layeredVoice.chordSynth2, 0.22, 0.05, 0.14, 0.68, 0.34)\nconfigureSynth(layeredVoice.chordSynth2b, isRunningOnMacDesktop() and 0 or 0.12, 0.055, 0.14, 0.48, 0.36)\n\nfunction createSequenceVoice()\n    local voice = {\n        synth = snd.synth.new(snd.kWaveFlute),\n        synth2 = snd.synth.new(snd.kWaveSine),\n        sampleSynth = nil,\n        -- Additional synths for chord notes\n        chordSynth1 = snd.synth.new(snd.kWaveFlute),\n        chordSynth1b = snd.synth.new(snd.kWaveSine),\n        chordSynth2 = snd.synth.new(snd.kWaveFlute),\n        chordSynth2b = snd.synth.new(snd.kWaveSine)\n    }\n    configureVoice(voice)\n    -- Chord synths use the same flute character at lower gain so stacks stay smooth.\n    configureSynth(voice.chordSynth1, 0.22, 0.05, 0.14, 0.68, 0.34)\n    configureSynth(voice.chordSynth1b, isRunningOnMacDesktop() and 0 or 0.12, 0.055, 0.14, 0.48, 0.36)\n    configureSynth(voice.chordSynth2, 0.22, 0.05, 0.14, 0.68, 0.34)\n    configureSynth(voice.chordSynth2b, isRunningOnMacDesktop() and 0 or 0.12, 0.055, 0.14, 0.48, 0.36)\n    return voice\nend\n\nlocal sequenceVoices = {\n    [1] = primaryVoice,\n    [2] = layeredVoice\n}\n\nfor i = 3, core.maxSequences do\n    sequenceVoices[i] = createSequenceVoice()\nend\n\nfunction stopVoice(voice)\n    if voice.sampleSynth then\n        voice.sampleSynth:noteOff()\n    end\n    if voice.synth then\n        voice.synth:stop()\n    end\n    if voice.synth2 then\n        voice.synth2:stop()\n    end\n    if voice.chordSynth1 then\n        voice.chordSynth1:stop()\n    end\n    if voice.chordSynth1b then\n        voice.chordSynth1b:stop()\n    end\n    if voice.chordSynth2 then\n        voice.chordSynth2:stop()\n    end\n    if voice.chordSynth2b then\n        voice.chordSynth2b:stop()\n    end\nend\n\nstopAllVoices = function()\n    for _, voice in pairs(sequenceVoices) do\n        stopVoice(voice)\n    end\n    if midiOut then\n        midiOut.allNotesOff()\n    end\nend\n\nsetAcapellaMode = function(enabled)\n    if state.acapellaMode == enabled then\n        return\n    end\n    state.acapellaMode = enabled\n    if enabled then\n        stopAllVoices()\n    end\n    savePreferences()\nend\n\nfunction getPlaybackLength()\n    local maxLength = 0\n    for i = 1, core.maxSequences do\n        if state.sequences[i] and not state.sequenceMutes[i] then\n            local length = state.sequenceLengths[i] or 0\n            if length > maxLength then\n                maxLength = length\n            end\n        end\n    end\n    return maxLength\nend\n\n-- Reused across calls to avoid a table allocation every playback step\n_seqIndicesCache = {}\n\nfunction getSequentialPlaybackSequenceIndices()\n    local n = 0\n    for i = 1, core.maxSequences do\n        local length = state.sequenceLengths[i] or 0\n        if length > 0 and not state.sequenceMutes[i] then\n            n = n + 1\n            _seqIndicesCache[n] = i\n        end\n    end\n    -- Trim stale entries left over from a previous longer result\n    for i = n + 1, #_seqIndicesCache do\n        _seqIndicesCache[i] = nil\n    end\n    return _seqIndicesCache\nend\n\ngetPlaybackSequence = function()\n    local sequenceIndex = state.activeSequenceIndex or 1\n    if state.isPlaying and state.currentPlaybackSequenceIndex then\n        sequenceIndex = state.currentPlaybackSequenceIndex\n    end\n    return state.sequences[sequenceIndex] or state.sequence\nend\n\nfunction stepHasPlayableAudio(stepData)\n    if not stepData or stepData.muted then\n        return false\n    end\n    if core.isChord(stepData) then\n        if not stepData.notes then\n            return false\n        end\n        for _, noteData in ipairs(stepData.notes) do\n            if noteData and noteData.note ~= nil and noteData.note ~= 13 and core.noteFreqs[noteData.note] ~= nil then\n                return true\n            end\n        end\n        return false\n    end\n    return stepData.note ~= nil and stepData.note ~= 13 and core.noteFreqs[stepData.note] ~= nil\nend\n\nfunction syncActiveSequenceState()\n    core.syncActiveSequenceState(state)\nend\n\nfunction setActiveSequence(index)\n    core.setActiveSequence(state, index)\nend\n\nfunction clearSequence()\n    recordStepHistoryIfNeeded()\n    core.clearSequence(state)\nend\n\nfunction copySequenceData(source, length)\n    return core.copySequenceData(source, length)\nend\n\nfunction addNewSequence()\n    core.addNewSequence(state)\nend\n\nfunction getSequenceMenuItems()\n    return core.getSequenceMenuItems(state)\nend\n\nfunction getStepMenuItems(seqIndex)\n    return core.getStepMenuItems(state, seqIndex)\nend\n\n-- Recording functions using the CORRECT API\nfunction startRecording()\n    print(\"Starting recording process...\")\n    stopPitchRecognition()\n    clearPitchRecognitionResults()\n    \n    -- Create a buffer to record into\n    -- According to docs: playdate.sound.sample.new(secondsToRecord, playdate.sound.kFormat16bitMono)\n    -- Let's try without the format constant first to see if it has a default\n    local buffer = snd.sample.new(recordingDuration)\n    \n    if not buffer then\n        print(\"Failed to create sample buffer\")\n        showRecordingScreen = false\n        return\n    end\n    \n    print(\"Buffer created successfully\")\n    \n    -- Start listening from the microphone\n    local success, source = snd.micinput.startListening()\n    if not success then\n        print(\"Failed to start listening\")\n        showRecordingScreen = false\n        return\n    end\n    \n    print(\"Recording from: \" .. tostring(source))\n    \n    -- Start recording into the buffer\n    local recordSuccess = snd.micinput.recordToSample(buffer, function(sample)\n        -- This callback is called when recording completes\n        print(\"Recording completed!\")\n        recordedSample = sample\n        useSampleMode = true\n        isRecording = false\n        showRecordingScreen = false\n        \n        -- Create a synth with the sample (synths support envelopes!)\n        if recordedSample then\n            -- Decompress the sample so it can be used in a synth\n            local success = recordedSample:decompress()\n            if success then\n                -- Extract a stable middle portion to use as a loop\n                -- Skip the first 0.5 seconds and last 0.5 seconds for 3 second recording\n                local sampleRate = 44100\n                local startFrame = sampleRate * 0.5 -- Skip first 0.5s\n                local endFrame = sampleRate * 2.5   -- Stop at 2.5s (2.0s of usable audio)\n                \n                -- Get the subsample (the stable middle part)\n                local loopSample = recordedSample:getSubsample(startFrame, endFrame)\n                \n                -- Create synth with the subsample and set it to loop\n                for i = 1, core.maxSequences do\n                    local voice = sequenceVoices[i]\n                    if voice then\n                        voice.sampleSynth = snd.synth.new(loopSample, 0, endFrame - startFrame)\n                        voice.sampleSynth:setADSR(0.005, 0.01, 1.0, 0.02)\n                    end\n                end\n                \n                print(\"Sample synth created with looping subsample!\")\n            else\n                print(\"Failed to decompress sample\")\n            end\n        end\n        \n        -- Stop listening\n        snd.micinput.stopListening()\n    end)\n    \n    if recordSuccess then\n        isRecording = true\n        recordingStartTime = getCurrentTimeMilliseconds()\n        print(\"Recording started successfully\")\n    else\n        print(\"Failed to start recordToSample\")\n        snd.micinput.stopListening()\n        showRecordingScreen = false\n    end\nend\n\nfunction stopRecording()\n    if isRecording then\n        snd.micinput.stopRecording()\n        -- The completion callback will be called automatically\n    end\nend\n\nfunction showRecordingUI()\n    showRecordingScreen = true\n    isRecording = false\nend\n\nfunction closeRecordingScreen()\n    if isRecording then\n        stopRecording()\n    end\n    showRecordingScreen = false\nend\n\n-- Helper function to get current pattern list\nfunction getCurrentPatternList()\n    return core.getCurrentPatternList(state)\nend\n\nfunction getPatternMenuItems()\n    local items = {\n        {\n            kind = \"patternType\",\n            label = \"Category:\",\n            value = function()\n                return core.patternTypes[state.selectedPatternType] or \"Unknown\"\n            end\n        },\n        {\n            kind = \"patternOctave\",\n            label = \"Octave:\",\n            value = function()\n                return tostring(state.patternOctave)\n            end\n        }\n    }\n\n    local patternList = getCurrentPatternList()\n    for index, pattern in ipairs(patternList) do\n        table.insert(items, {\n            kind = \"pattern\",\n            label = index .. \". \" .. pattern.name,\n            patternIndex = index\n        })\n    end\n\n    return items\nend\n\nfunction adjustPatternType(direction)\n    local wasCategorySelected = state.selectedPatternListIndex == 1\n    local nextType = state.selectedPatternType + direction\n    if nextType < 1 then\n        nextType = #core.patternTypes\n    elseif nextType > #core.patternTypes then\n        nextType = 1\n    end\n    state.selectedPatternType = nextType\n    syncPatternSelection()\n    if wasCategorySelected then\n        state.selectedPatternListIndex = 1\n    end\nend\n\nfunction adjustPatternOctave(direction)\n    state.patternOctave = state.patternOctave + direction\n    if state.patternOctave < 2 then\n        state.patternOctave = 7\n    elseif state.patternOctave > 7 then\n        state.patternOctave = 2\n    end\n    savePreferences()\nend\n\nfunction isSelectablePatternMenuItem(item)\n    return item and item.kind ~= \"label\"\nend\n\nfunction movePatternListSelection(direction)\n    local items = getPatternMenuItems()\n    local totalItems = #items\n    if totalItems == 0 then\n        return\n    end\n\n    local nextIndex = state.selectedPatternListIndex\n    for _ = 1, totalItems do\n        nextIndex = nextIndex + direction\n        if nextIndex < 1 then\n            nextIndex = totalItems\n        elseif nextIndex > totalItems then\n            nextIndex = 1\n        end\n\n        local item = items[nextIndex]\n        if isSelectablePatternMenuItem(item) then\n            state.selectedPatternListIndex = nextIndex\n            if item.kind == \"pattern\" then\n                state.selectedPattern = item.patternIndex\n                state.selectedPatternByType[state.selectedPatternType] = state.selectedPattern\n            end\n            return\n        end\n    end\nend\n\nsyncPatternSelection = function()\n    local patternList = getCurrentPatternList()\n    local savedSelection = state.selectedPatternByType[state.selectedPatternType] or 1\n    if savedSelection < 1 then\n        savedSelection = 1\n    elseif savedSelection > #patternList then\n        savedSelection = #patternList\n    end\n    state.selectedPatternByType[state.selectedPatternType] = savedSelection\n    state.selectedPattern = savedSelection\n    state.selectedPatternListIndex = 3 + savedSelection\nend\n\n-- Function to load a pattern into the sequencer\nfunction loadPattern(patternIndex)\n    local patternList = getCurrentPatternList()\n    local pattern = patternList[patternIndex]\n    if not pattern then return end\n    setActiveSequence(state.patternTargetSequenceIndex)\n    recordStepHistoryIfNeeded()\n    if core.loadPattern(state, patternIndex) then\n        markSequenceDirty()\n    end\nend\n\n-- Forward declaration so saveSequence (defined here) can reference writeTextFile\n-- (which is defined later at the point where it depends on dirname/ensureDirectoryExists).\nlocal writeTextFile\nwriteBinaryFile = nil  -- forward declaration; assigned below\n\n-- ===== SEQUENCE BACKUPS (delegated to project_manager.lua) =====\nlocal backupFileLabel = pm.backupFileLabel\n\nlocal function getBackupsDirectory()\n    return pm.getBackupsDirectory(autoSaveMusicXMLFilename, _storageRoot)\nend\n\nlocal function createSequenceBackup()\n    pm.createSequenceBackup(autoSaveMusicXMLFilename, _storageRoot)\nend\n-- ===== END SEQUENCE BACKUPS =====\n\n-- Save sequence to file\nfunction saveSequence(showMessage)\n    createSequenceBackup()\n    syncActiveSequenceState()\n    storage.saveSequence(storageAdapter.write, saveFilename, state)\n\n    local xmlSaved, xmlError = MusicXML.exportMusicXMLFile(state, core, autoSaveMusicXMLFilename, writeTextFile)\n\n    local linkedDocxSaved = false\n    local linkedDocxError = nil\n    local linkedDocxPath = tostring(state.linkedLyricsDocxPath or \"\")\n    if linkedDocxPath ~= \"\" then\n        linkedDocxSaved, linkedDocxError = LyricsImport.exportDocxLyricsFile(state, linkedDocxPath)\n        local _dbgL = io.open(\"/tmp/solfege_debug.log\", \"a\")\n        if _dbgL then\n            _dbgL:write(string.format(\"[docx-linked] ok=%s err=%s path=%s time=%s\\n\",\n                tostring(linkedDocxSaved), tostring(linkedDocxError), linkedDocxPath, os.date(\"%H:%M:%S\")))\n            _dbgL:close()\n        end\n    end\n\n    -- Write lyrics.docx into the .solfege package bundle and as a sibling file\n    local _xmlPath = tostring(autoSaveMusicXMLFilename or \"\")\n    local _pkgDir = _xmlPath:match(\"^(.+%.solfege)/[^/]+%.musicxml$\")\n    if _pkgDir then\n        local _docxOk, _docxErr = LyricsImport.exportDocxLyricsFile(state, _pkgDir .. \"/lyrics.docx\")\n        local _dbgD = io.open(\"/tmp/solfege_debug.log\", \"a\")\n        if _dbgD then\n            _dbgD:write(string.format(\"[docx-bundle] ok=%s err=%s path=%s time=%s\\n\",\n                tostring(_docxOk), tostring(_docxErr), _pkgDir .. \"/lyrics.docx\", os.date(\"%H:%M:%S\")))\n            _dbgD:close()\n        end\n        -- Also write a standalone lyrics.docx next to the package so it can be opened directly\n        local _pkgParent = _pkgDir:match(\"^(.+)/[^/]+%.solfege$\")\n        local _pkgStem = _pkgDir:match(\"/([^/]+)%.solfege$\")\n        if _pkgParent and _pkgStem then\n            LyricsImport.exportDocxLyricsFile(state, _pkgParent .. \"/\" .. _pkgStem .. \"-lyrics.docx\")\n        end\n\n        -- Save lyric notes buffer as a versioned docx inside the bundle\n        local _lnBuf = state.lyricNotesBuffer or \"\"\n        if _lnBuf:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\") ~= \"\" then\n            local _maxVer = 0\n            local _lsPipe = io.popen(\"ls \" .. _bkShellQuote(_pkgDir) .. \"/lyrics_notes_v*.docx 2>/dev/null\", \"r\")\n            if _lsPipe then\n                for _lsLine in _lsPipe:lines() do\n                    local _v = tonumber(_lsLine:match(\"lyrics_notes_v(%d+)%.docx$\"))\n                    if _v and _v > _maxVer then _maxVer = _v end\n                end\n                _lsPipe:close()\n            end\n            local _nextVer = _maxVer + 1\n            local _lnPath = string.format(\"%s/lyrics_notes_v%03d.docx\", _pkgDir, _nextVer)\n            LyricsImport.exportDocxLyricNotesFile(state, _lnPath)\n        end\n    end\n\n    local _dbg = io.open(\"/tmp/solfege_debug.log\", \"a\")\n    if _dbg then\n        local noteCount = 0\n        local lyricCount = 0\n        if state.sequence then\n            for _, s in pairs(state.sequence) do\n                noteCount = noteCount + 1\n                if s and s.lyric and s.lyric ~= \"\" then lyricCount = lyricCount + 1 end\n            end\n        end\n        local bufSnip = (state.solfegeInputBuffer or \"\"):sub(1,40):gsub(\"\\n\",\" \")\n        _dbg:write(string.format(\"[saveSequence] file=%s notes=%d lyrics=%d mode=%s seqLen=%d buf=%q xmlOk=%s time=%s\\n\",\n            tostring(autoSaveMusicXMLFilename), noteCount, lyricCount,\n            tostring(state.solfegeTextMode), tostring(state.sequenceLength),\n            bufSnip, tostring(xmlSaved), os.date(\"%H:%M:%S\")))\n        _dbg:close()\n    end\n\n    if not xmlSaved then\n        print(\"Auto-save MusicXML failed: \" .. tostring(xmlError))\n        local _dbe = io.open(\"/tmp/solfege_debug.log\", \"a\")\n        if _dbe then\n            _dbe:write(string.format(\"[saveSequence] FAILED err=%s time=%s\\n\",\n                tostring(xmlError), os.date(\"%H:%M:%S\")))\n            _dbe:close()\n        end\n    end\n\n    if linkedDocxPath ~= \"\" and not linkedDocxSaved then\n        print(\"Linked DOCX export failed: \" .. tostring(linkedDocxError))\n        local _dbe = io.open(\"/tmp/solfege_debug.log\", \"a\")\n        if _dbe then\n            _dbe:write(string.format(\"[saveSequence] LINKED_DOCX_FAILED err=%s path=%s time=%s\\n\",\n                tostring(linkedDocxError), linkedDocxPath, os.date(\"%H:%M:%S\")))\n            _dbe:close()\n        end\n    end\n\n    -- Sync MusicXML to OneDrive (web PWA)\n    local _odSync = rawget(_G, \"_oneDriveBridge\")\n    if _odSync and _odSync.isSignedIn and _odSync.isSignedIn() and _odSync.syncMusicXML and xmlSaved then\n        local xmlName = (state.musicXMLFileName or \"export\") .. \".musicxml\"\n        local xmlContent = MusicXML.exportStateToString(state, core)\n        if xmlContent then\n            _odSync.syncMusicXML(xmlName, xmlContent)\n        end\n    end\n\n    if showMessage or xmlSaved then\n        showSaveMessage = true\n        saveMessageTimer = 60 -- Show for 1 second (60 frames)\n    end\nend\n\n-- Load sequence from file\nfunction loadSequence()\n    if storage.loadSequence(storageAdapter.read, saveFilename, state, core, setTempo) then\n        resetStepHistory()\n        -- Sync selectedNote with the current step after load\n        local _cs = state.sequence[state.currentStep]\n        if _cs then\n            if core.isChord(_cs) then\n                local fn = _cs.notes and _cs.notes[1]\n                if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n            elseif _cs.note ~= nil and _cs.note ~= 13 then\n                state.selectedNote = _cs.note\n                state.currentOctave = _cs.octave\n            end\n        else\n            state.selectedNote = 0\n        end\n        state._solfegeSeqText = serializeSequenceToText()\n        state.solfegeInputBuffer = state._solfegeSeqText\n        showSaveMessage = true\n        saveMessageTimer = 60\n        return true\n    end\n    return false\nend\n\nfunction selectTemplateById(templateId, categoryName)\n    if not templateId or not categoryName then\n        return\n    end\n\n    local categoryIndex = nil\n    for i, name in ipairs(templateLibrary.categories) do\n        if name == categoryName then\n            categoryIndex = i\n            break\n        end\n    end\n\n    if not categoryIndex then\n        return\n    end\n\n    local templates = templateLibrary.getTemplatesByCategory(categoryName)\n    for i, template in ipairs(templates) do\n        if template and template.id == templateId then\n            state.selectedTemplateCategory = categoryIndex\n            state.selectedTemplateIndex = i\n            return\n        end\n    end\nend\n\n-- Show a native macOS file picker using the compiled filepicker helper.\n-- exts: comma-separated extensions e.g. \"musicxml,xml\" or \"mid,midi\"\n-- Uses a temp file so the picker runs as a proper foreground app.\nlocal function pickFile(prompt, exts)\n    local helperPath = debug.getinfo(1, \"S\").source:match(\"@?(.*/)\")\n    helperPath = (helperPath or \"./\") .. \"tools/filepicker\"\n    local tmpFile = os.tmpname() .. \".txt\"\n    local cmd = string.format('%q %q %q %q >/dev/null 2>&1', helperPath, prompt, exts, tmpFile)\n    os.execute(cmd)\n    local f = io.open(tmpFile, \"r\")\n    if not f then return nil end\n    local path = f:read(\"*l\")\n    f:close()\n    os.remove(tmpFile)\n    if path and path ~= \"\" then return path end\n    return nil\nend\n\nfunction pickFolder(prompt)\n    if not isRunningOnMacDesktop() then\n        return nil\n    end\n\n    local tmpFile = os.tmpname() .. \".txt\"\n    local appleScriptPrompt = tostring(prompt or \"Select Folder\")\n    appleScriptPrompt = appleScriptPrompt:gsub(\"\\\\\", \"\\\\\\\\\")\n    appleScriptPrompt = appleScriptPrompt:gsub('\"', '\\\\\"')\n\n    local script = string.format('POSIX path of (choose folder with prompt \"%s\")', appleScriptPrompt)\n    local quotedScript = \"'\" .. script:gsub(\"'\", \"'\\\\''\") .. \"'\"\n    local quotedTmpFile = \"'\" .. tmpFile:gsub(\"'\", \"'\\\\''\") .. \"'\"\n    local status = os.execute(\"osascript -e \" .. quotedScript .. \" > \" .. quotedTmpFile .. \" 2>/dev/null\")\n    if not (status == true or status == 0) then\n        os.remove(tmpFile)\n        return nil\n    end\n\n    local file = io.open(tmpFile, \"r\")\n    if not file then\n        os.remove(tmpFile)\n        return nil\n    end\n\n    local path = file:read(\"*l\")\n    file:close()\n    os.remove(tmpFile)\n\n    if type(path) == \"string\" and path ~= \"\" then\n        return path:gsub(\"[/\\\\]+$\", \"\")\n    end\n\n    return nil\nend\n\nfunction connectOneDriveFromWelcome()\n    -- Web PWA: use OneDrive API via browser\n    local od = rawget(_G, \"_oneDriveBridge\")\n    if od and od.isAvailable and od.isAvailable() then\n        if not od.isConfigured or not od.isConfigured() then\n            showImportMessage = true\n            importMessageTimer = 300\n            importMessageText = \"OneDrive not configured (set VITE_ONEDRIVE_CLIENT_ID)\"\n            return false\n        end\n        if od.isSignedIn() then\n            od.triggerSignOut()\n            _oneDriveWelcomeRoot = false\n            showImportMessage = true\n            importMessageTimer = 180\n            importMessageText = \"OneDrive Disconnected\"\n            return true\n        end\n        od.triggerSignIn()\n        _oneDriveSignInPending = true\n        showImportMessage = true\n        importMessageTimer = 600\n        importMessageText = \"Signing in to OneDrive...\"\n        return true\n    end\n\n    -- Desktop: pick local OneDrive folder\n    local rootPath = pickFolder(\"Select your OneDrive folder\")\n    if not rootPath or rootPath == \"\" then\n        return false\n    end\n\n    local oneDrivePaths = package.loaded[\"onedrive_paths\"] or import \"onedrive_paths\"\n    local saved, saveError = oneDrivePaths.saveRoot(rootPath)\n    if not saved then\n        print(\"OneDrive setup failed: \" .. tostring(saveError))\n        showImportMessage = true\n        importMessageTimer = 180\n        importMessageText = \"⚠ OneDrive Setup Failed\"\n        return false\n    end\n\n    local dirs = oneDrivePaths.resolveAppDirectories({\n        getenv = function(name)\n            if name == \"SOLFEGE_ONEDRIVE_DIR\" then\n                return rootPath\n            end\n            return os.getenv(name)\n        end\n    })\n\n    if dirs and dirs.musicXMLRoot then\n        _oneDriveWelcomeRoot = rootPath\n        local quotedMusicXmlDir = \"'\" .. dirs.musicXMLRoot:gsub(\"'\", \"'\\\\''\") .. \"'\"\n        os.execute(\"mkdir -p \" .. quotedMusicXmlDir)\n        musicXMLStorageDirectory = dirs.musicXMLRoot\n        autoSaveMusicXMLFilename = pkgXMLFilename(resolveMusicXMLStoragePath(\"export.solfege\"))\n        welcomeRecentFiles = findRecentMusicXMLFiles(50)\n        state._welcomeFirstVisible = 1\n    end\n\n    showImportMessage = true\n    importMessageTimer = 240\n    importMessageText = \"✓ OneDrive Connected (relaunch for app data)\"\n    return true\nend\n\nfunction importDocxFromWelcome()\n    local function logStep(step, detail)\n        local _d = io.open(\"/tmp/solfege_debug.log\", \"a\")\n        if _d then\n            _d:write(string.format(\"[importDocxFromWelcome] step=%s detail=%s time=%s\\n\",\n                tostring(step), tostring(detail), os.date(\"%H:%M:%S\")))\n            _d:close()\n        end\n    end\n\n    local callOk, result = pcall(function()\n        logStep(\"pick_start\", \"docx\")\n        local path = pickFile(\"Select a DOCX lyrics file\", \"docx\")\n        logStep(\"pick_done\", path)\n        if not path then\n            return false\n        end\n\n        closeWelcomeScreen()\n        logStep(\"welcome_closed\", path)\n\n        local created = type(_G._testCreateNewProject) == \"function\" and _G._testCreateNewProject() or false\n        logStep(\"project_created\", created)\n        if not created then\n            return false\n        end\n\n        state.linkedLyricsDocxPath = path\n        local imported = importDocxLyrics(path)\n        logStep(\"import_finished\", imported)\n        if imported then\n            state.showLyrics = true\n        end\n        return imported\n    end)\n\n    if not callOk then\n        local _d = io.open(\"/tmp/solfege_debug.log\", \"a\")\n        if _d then\n            _d:write(string.format(\"[importDocxFromWelcome] crashed=%s time=%s\\n\", tostring(result), os.date(\"%H:%M:%S\")))\n            _d:close()\n        end\n        print(\"Start-screen DOCX import crashed: \" .. tostring(result))\n        showImportMessage = true\n        importMessageTimer = 180\n        importMessageText = \"⚠ DOCX Import Failed\"\n        return false\n    end\n\n    return result and true or false\nend\n\nlocal normalizeMusicXMLFilename = pm.normalizeMusicXMLFilename\nlocal dirname = pm.dirname\nlocal ensureDirectoryExists = pm.ensureDirectoryExists\n\nlocal function startMusicXMLFilenameEditing()\n    if not state.musicXMLFileName then\n        return\n    end\n    if state.musicXMLFilenameEditing then\n        return  -- already editing, don't reinitialize the buffer\n    end\n    state.musicXMLFilenameEditing = true\n    state.pathPopupOpen = false\n    local buf = tostring(state.musicXMLFileName or \"\")\n    buf = buf:gsub(\"%.[Mm][Uu][Ss][Ii][Cc][Xx][Mm][Ll]$\", \"\"):gsub(\"%.[Xx][Mm][Ll]$\", \"\"):gsub(\"%.solfege$\", \"\")\n    state.musicXMLFilenameInputBuffer = buf\n    state.musicXMLFilenameInputCursor = #buf\nend\n\nlocal function cancelMusicXMLFilenameEditing()\n    state.musicXMLFilenameEditing = false\n    state.musicXMLFilenameInputBuffer = nil\n    state.musicXMLFilenameInputCursor = nil\n    state._filenameCharXPositions = nil\nend\n\nlocal function commitMusicXMLFilenameEditing()\n    -- nextFilename is now a .solfege package name (e.g. \"project-1.solfege\")\n    local nextFilename = normalizeMusicXMLFilename(state.musicXMLFilenameInputBuffer)\n    if not nextFilename then\n        cancelMusicXMLFilenameEditing()\n        return false\n    end\n\n    local oldXML = tostring(autoSaveMusicXMLFilename or \"\")\n    local newPkgPath = resolveMusicXMLStoragePath(nextFilename)\n    local newXML = pkgXMLFilename(newPkgPath)\n\n    -- Rename the .solfege package directory when the name actually changes.\n    local oldPkg = oldXML:match(\"^(.+%.solfege)/[^/]+%.musicxml$\")\n    if oldPkg and oldPkg ~= newPkgPath then\n        os.rename(oldPkg, newPkgPath)\n        -- Rename the internal musicxml file to match the new package name.\n        local oldInternalName = oldXML:match(\"^.+%.solfege/([^/]+%.musicxml)$\")\n        if oldInternalName then\n            local movedFile = newPkgPath .. \"/\" .. oldInternalName\n            if movedFile ~= newXML then\n                os.rename(movedFile, newXML)\n            end\n        end\n    elseif not oldPkg and oldXML ~= \"\" and oldXML ~= newXML then\n        -- Legacy .musicxml file: just let markSequenceDirty write to new package path.\n    end\n\n    autoSaveMusicXMLFilename = newXML\n    state.musicXMLFileName = nextFilename\n    cancelMusicXMLFilenameEditing()\n    markSequenceDirty()\n    return true\nend\n\nfunction renameCmdChatProject(name)\n    local nextName = tostring(name or \"\"):match(\"^%s*(.-)%s*$\")\n    if nextName == \"\" then\n        return false, \"Usage: project name <name>\"\n    end\n    state.musicXMLFilenameInputBuffer = nextName\n    state.musicXMLFilenameInputCursor = #nextName\n    local ok = commitMusicXMLFilenameEditing()\n    if ok then\n        return true, state.musicXMLFileName or normalizeMusicXMLFilename(nextName) or nextName\n    end\n    return false, \"Could not rename project\"\nend\n\n\nfunction importMidi(filename)\n    local ok, result = MidiImport.importMidiFile(state, core, filename, storageAdapter.readBinary)\n    if ok then\n        if state.isPlaying then\n            stopPlayback()\n        end\n        markSequenceDirty()\n        state.musicXMLFileName = nil\n        showWelcomeScreen = false\n        showImportMessage = true\n        importMessageTimer = 120\n        importMessageText = \"✓ MIDI Imported!\"\n        return true\n    end\n    print(\"MIDI import failed: \" .. tostring(result))\n    return false\nend\n\nfunction readTextFile(filename)\n    -- Use io.open directly (symmetric with writeTextFile) so that relative\n    -- paths like \"../solfege_musicxml/project.musicxml\" resolve against the\n    -- CWD rather than being routed through the storage adapter's\n    -- resolveStoragePath, which incorrectly prepends the Application Support\n    -- directory to non-absolute paths.\n    local file = io.open(filename, \"rb\")\n    if not file then\n        print(\"Could not read file: Could not open file: \" .. tostring(filename))\n        return nil\n    end\n    local data = file:read(\"*a\")\n    file:close()\n    return data\nend\n\nwriteTextFile = function(filename, contents)\n    if platformAdapters.name == \"web\" then return true end\n    ensureDirectoryExists(filename)\n    local file = io.open(filename, \"w\")\n    if not file then\n        return false, \"Could not open file for writing: \" .. tostring(filename)\n    end\n    file:write(contents)\n    file:close()\n    return true\nend\n\nwriteBinaryFile = function(filename, contents)\n    if platformAdapters.name == \"web\" then return true end\n    ensureDirectoryExists(filename)\n    local file = io.open(filename, \"wb\")\n    if not file then\n        return false, \"Could not open file for writing: \" .. tostring(filename)\n    end\n    file:write(contents)\n    file:close()\n    return true\nend\n\nfunction importMusicXML(filename)\n    -- Support .solfege package directories: open the musicxml inside.\n    -- Prefer <pkgname>.musicxml (new naming), fall back to sequence.musicxml (legacy).\n    local xmlFile = filename\n    if type(filename) == \"string\" and filename:match(\"%.solfege$\") then\n        local preferred = pkgXMLFilename(filename)\n        local f = io.open(preferred, \"r\")\n        if f then f:close(); xmlFile = preferred\n        else xmlFile = filename .. \"/sequence.musicxml\" end\n    end\n    local ok, result = MusicXML.importMusicXMLFile(state, core, xmlFile, readTextFile)\n    do\n        local _d = io.open(\"/tmp/solfege_debug.log\", \"a\")\n        if _d then\n            _d:write(string.format(\"[importMusicXML] file=%s ok=%s err=%s time=%s\\n\",\n                tostring(xmlFile), tostring(ok), tostring(result), os.date(\"%H:%M:%S\")))\n            _d:close()\n        end\n    end\n    if ok then\n        if state.isPlaying then\n            stopPlayback()\n        end\n        setTempo(state.tempo or state.defaultTempo)\n        setStepBeats(state.stepBeats or 1)\n        -- Sync selectedNote with the current step after load\n        local _cs = state.sequence[state.currentStep]\n        if _cs then\n            if core.isChord(_cs) then\n                local fn = _cs.notes and _cs.notes[1]\n                if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n            elseif _cs.note ~= nil and _cs.note ~= 13 then\n                state.selectedNote = _cs.note\n                state.currentOctave = _cs.octave\n            end\n        else\n            state.selectedNote = 0\n        end\n        autoSaveMusicXMLFilename = tostring(xmlFile)\n        -- Display name: package stem for .solfege packages, file basename otherwise\n        local pkg = xmlFile:match(\"^(.+%.solfege)/[^/]+%.musicxml$\")\n        state.musicXMLFileName = pkg and basename(pkg) or basename(xmlFile)\n\n        -- Re-import lyrics and lyric notes from docx files so\n        -- edits made externally (e.g. in Word) are picked up on project open.\n        if pkg then\n            local lyricsDocx = nil\n            local linkedLyricsDocxPath = tostring(state.linkedLyricsDocxPath or \"\")\n            if linkedLyricsDocxPath ~= \"\" then\n                local linkedFile = io.open(linkedLyricsDocxPath, \"r\")\n                if linkedFile then\n                    linkedFile:close()\n                    lyricsDocx = linkedLyricsDocxPath\n                end\n            end\n            if not lyricsDocx then\n                local bundledLyricsDocx = pkg .. \"/lyrics.docx\"\n                local lf = io.open(bundledLyricsDocx, \"r\")\n                if lf then\n                    lf:close()\n                    lyricsDocx = bundledLyricsDocx\n                end\n            end\n            if lyricsDocx then\n                pcall(LyricsImport.importDocxLyricsFile, state, lyricsDocx)\n            end\n\n            -- Find the highest-versioned lyrics_notes docx in the bundle\n            local _maxVer = 0\n            local _lsPipe = io.popen(\"ls \" .. _bkShellQuote(pkg) .. \"/lyrics_notes_v*.docx 2>/dev/null\", \"r\")\n            if _lsPipe then\n                for _lsLine in _lsPipe:lines() do\n                    local _v = tonumber(_lsLine:match(\"lyrics_notes_v(%d+)%.docx$\"))\n                    if _v and _v > _maxVer then _maxVer = _v end\n                end\n                _lsPipe:close()\n            end\n            if _maxVer > 0 then\n                local _lnDocx = string.format(\"%s/lyrics_notes_v%03d.docx\", pkg, _maxVer)\n                pcall(LyricsImport.importDocxLyricNotesFile, state, _lnDocx)\n            end\n        end\n\n        if state.solfegeInputBuffer and state.solfegeInputBuffer ~= \"\" then\n            state._preserveInputBuffer = true\n        end\n        markSequenceDirty()\n        state._preserveInputBuffer = nil\n        -- markSequenceDirty no longer auto-populates the buffer in lyrics mode\n        -- (buffer is source of truth there). After a project load, populate it\n        -- from step data if it's still empty.\n        if (state.solfegeTextMode == \"lyrics\") and (not state.solfegeInputBuffer or state.solfegeInputBuffer == \"\") then\n            state.solfegeInputBuffer = serializeSequenceToText()\n            state._solfegeSeqText = state.solfegeInputBuffer\n        end\n        showWelcomeScreen = false\n        showImportMessage = true\n        importMessageTimer = 120\n        importMessageText = \"✓ MusicXML Imported!\"\n        return true\n    end\n    print(\"MusicXML import failed: \" .. tostring(result))\n    return false\nend\n\nfunction importDocxLyrics(filename)\n    local callOk, ok, result = pcall(LyricsImport.importDocxLyricsFile, state, filename)\n\n    local _d = io.open(\"/tmp/solfege_debug.log\", \"a\")\n    if _d then\n        _d:write(string.format(\"[importDocxLyrics] file=%s pcall=%s ok=%s result=%s time=%s\\n\",\n            tostring(filename), tostring(callOk), tostring(ok), tostring(result), os.date(\"%H:%M:%S\")))\n        _d:close()\n    end\n\n    if not callOk then\n        print(\"DOCX lyrics import crashed: \" .. tostring(ok))\n        showImportMessage = true\n        importMessageTimer = 180\n        importMessageText = \"⚠ DOCX Import Failed\"\n        return false\n    end\n\n    if ok then\n        if state.isPlaying then\n            stopPlayback()\n        end\n        markSequenceDirty()\n        showImportMessage = true\n        importMessageTimer = 120\n        importMessageText = \"✓ Lyrics Imported!\"\n        return true\n    end\n    print(\"DOCX lyrics import failed: \" .. tostring(result))\n    showImportMessage = true\n    importMessageTimer = 180\n    importMessageText = \"⚠ DOCX Import Failed\"\n    return false\nend\n\nfunction runLyricsSpellCheck()\n    local result = LyricsSpellcheck.checkSequenceLyrics(state.sequence)\n    if not result.ok then\n        print(\"Lyrics spell check failed: \" .. tostring(result.error))\n        showImportMessage = true\n        importMessageTimer = 180\n        importMessageText = \"⚠ Spell Check Unavailable\"\n        return false\n    end\n\n    if result.checkedWordCount == 0 then\n        showImportMessage = true\n        importMessageTimer = 180\n        importMessageText = \"⚠ No Lyrics to Check\"\n        return true\n    end\n\n    local misspelled = result.misspelledWords or {}\n    if #misspelled == 0 then\n        showImportMessage = true\n        importMessageTimer = 180\n        importMessageText = \"✓ Lyrics Spelling OK\"\n        return true\n    end\n\n    local previewCount = math.min(2, #misspelled)\n    local preview = table.concat(misspelled, \", \", 1, previewCount)\n    if #misspelled > previewCount then\n        preview = preview .. \", …\"\n    end\n\n    print(\"Lyrics spell check misspellings (\" .. tostring(#misspelled) .. \"): \" .. table.concat(misspelled, \", \"))\n    showImportMessage = true\n    importMessageTimer = 180\n    importMessageText = \"⚠ Check: \" .. preview\n    return true\nend\n\nfunction importLyricNotesText(lyricsText)\n    local tokens = LyricsImport.tokenizeLyricsText(lyricsText)\n    if #tokens == 0 then\n        return false\n    end\n\n    local importStepCount = state.sequenceLength\n    if importStepCount <= 0 then\n        importStepCount = #tokens\n    else\n        -- Keep assigning lyric tokens even when there are fewer note-bearing steps.\n        importStepCount = math.max(importStepCount, #tokens)\n    end\n    importStepCount = math.min(importStepCount, core.maxSteps)\n    if importStepCount <= 0 then\n        return false\n    end\n\n    for i = 1, importStepCount do\n        local step = state.sequence[i]\n        if not step then\n            step = {}\n            state.sequence[i] = step\n        end\n        step.lyric = nil\n    end\n\n    local assigned = 0\n    for i = 1, math.min(importStepCount, #tokens) do\n        state.sequence[i].lyric = tokens[i]\n        assigned = assigned + 1\n    end\n\n    if assigned > 0 and state.sequenceLength < assigned then\n        state.sequenceLength = assigned\n        state.sequenceLengths[state.activeSequenceIndex] = assigned\n    end\n\n    if assigned > 0 then\n        markSequenceDirty()\n        showImportMessage = true\n        importMessageTimer = 120\n        importMessageText = \"✓ Lyrics Imported!\"\n        return true\n    end\n\n    return false\nend\n\nfunction dropLyricTokenOnTimeline(token, x, y)\n    if type(token) ~= \"string\" or token == \"\" or state.isPlaying then\n        return false\n    end\n\n    local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n    if not grid then\n        return false\n    end\n\n    local spanX = grid.stepWidth + grid.horizontalGap\n    local spanY = grid.stepHeight + grid.verticalGap\n    local _sc = grid.gridScrollCol or 0\n    local col = math.floor((x - grid.startX) / spanX) + _sc\n    local row = math.floor((y - grid.startY) / spanY)\n    if col < _sc or col >= grid.stepsPerRow or row < 0 or row >= grid.rowsToShow then\n        return false\n    end\n\n    local stepIdx = row * grid.stepsPerRow + col + 1\n    if stepIdx < 1 or stepIdx > core.maxSteps then\n        return false\n    end\n\n    local step = state.sequence[stepIdx]\n    if not step then\n        step = {}\n        state.sequence[stepIdx] = step\n    end\n    step.lyric = token\n\n    if (state.sequenceLength or 0) < stepIdx then\n        state.sequenceLength = stepIdx\n        state.sequenceLengths[state.activeSequenceIndex] = stepIdx\n    end\n\n    markSequenceDirty()\n    return true\nend\n\nfunction removeLyricNotesToken(token)\n    if type(token) ~= \"string\" or token == \"\" then\n        return false\n    end\n\n    local tokens = LyricsImport.tokenizeLyricsText(state.lyricNotesBuffer or \"\")\n    for i, existingToken in ipairs(tokens) do\n        if existingToken == token then\n            table.remove(tokens, i)\n            state.lyricNotesBuffer = table.concat(tokens, \" \")\n            return true\n        end\n    end\n\n    return false\nend\n\nfunction dropLyricTokenOnLyricNotes(token, x, y)\n    if type(token) ~= \"string\" or token == \"\" then\n        return false\n    end\n\n    local panel = state._lyricNotesPanelBounds\n    if not state.lyricNotesPanelOpen or not panel then\n        return false\n    end\n\n    local inPanel = x >= panel.x and y >= panel.y and x < panel.x + panel.width and y < panel.y + panel.height\n    if not inPanel then\n        return false\n    end\n\n    local existingTokens = LyricsImport.tokenizeLyricsText(state.lyricNotesBuffer or \"\")\n    for _, existingToken in ipairs(existingTokens) do\n        if existingToken == token then\n            state.lyricNotesInputActive = true\n            return false\n        end\n    end\n\n    local existing = state.lyricNotesBuffer or \"\"\n    if existing ~= \"\" and not existing:match(\"[%s%-]$\") then\n        existing = existing .. \" \"\n    end\n    state.lyricNotesBuffer = existing .. token\n    state.lyricNotesInputActive = true\n    return true\nend\n\nfunction toggleLyricNotesDetached()\n    state.lyricNotesDetached = not (state.lyricNotesDetached == true)\n    local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if host and host.textWindow then\n        if state.lyricNotesDetached and state.lyricNotesPanelOpen then\n            if state.solfegeTextInputSide == \"window\" then\n                state.solfegeTextInputSide = \"bottom\"\n            end\n            if host.textWindow.isOpen() then host.textWindow.close() end\n            host.textWindow.open(\"Lyric Notes\", 420, 360)\n        elseif host.textWindow.isOpen() and state.solfegeTextInputSide ~= \"window\" then\n            host.textWindow.close()\n        end\n    end\nend\n\nlocal function openLyricsWindow()\n    state._modeSwOldBuf = state.solfegeInputBuffer or \"\"\n    if state.solfegeInputActive then commitSolfegeInput() end\n    state.solfegeTextMode = \"lyrics\"\n    state.solfegeInputBuffer = serializeSequenceToText()\n    state._solfegeSeqText = state.solfegeInputBuffer\n    state._modeSwOldBuf = nil\n    state.solfegeInputActive = false\n    state.showSolfegeTextInput = true\n    state.solfegeTextInputSide = \"window\"\n    state.solfegeTextOnlyMode = false\n    state.lyricNotesDetached = false\n    local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if host and host.textWindow then\n        if host.textWindow.isOpen() then host.textWindow.close() end\n        host.textWindow.open(\"Lyrics\", 600, 480)\n    end\n    savePreferences()\nend\n\nfunction lyricStepTextPosFromPoint(x, y)\n    local bounds = state._lyricStepInputBounds\n    if not bounds or state.lyricEditingStepIndex ~= bounds.stepIndex then return nil end\n    if y < bounds.y or y >= bounds.y + bounds.h then return nil end\n    -- Match the font used during rendering so measurements align with the visual layout.\n    if ui and ui.setMeasureFont then ui.setMeasureFont(bounds.font or \"small\") end\n    return textEditorPosFromSingleLine(bounds.text or \"\", x - bounds.x)\nend\n\n-- Helper: given a 0-based cursor position in buf, return (wordStart0, wordEnd0) for\n-- the word under/adjacent to that position, or nil if not on a word character.\n-- Positions are 0-based (0 = before first char, #buf = after last char).\nfunction findWordBoundsAt0(buf, p0)\n    if type(buf) ~= \"string\" or #buf == 0 then return nil end\n    local rightChar = (p0 < #buf)  and buf:sub(p0 + 1, p0 + 1) or \"\"\n    local leftChar  = (p0 > 0)     and buf:sub(p0,     p0)     or \"\"\n    if not rightChar:match(\"[%w_'%-]\") and not leftChar:match(\"[%w_'%-]\") then return nil end\n    -- Pick the \"anchor\" char (prefer right; fall back to left)\n    local i = rightChar:match(\"[%w_'%-]\") and (p0 + 1) or p0\n    -- Scan left to start of word\n    while i > 1 and buf:sub(i - 1, i - 1):match(\"[%w_'%-]\") do i = i - 1 end\n    local ws = i - 1  -- 0-based: position before first word char\n    -- Scan right to end of word\n    local j = i\n    while j <= #buf and buf:sub(j, j):match(\"[%w_'%-]\") do j = j + 1 end\n    local we = j - 1  -- 0-based: position after last word char\n    if we <= ws then return nil end\n    return ws, we\nend\n\n-- Returns a 0-indexed buffer position from mouse coords in the lyric notes input box, or nil.\nfunction lyricNotesTextPosFromPoint(x, y)\n    local panel = state._lyricNotesPanelBounds\n    if not panel then return nil end\n    local lineMap = state._lyricNotesInputLineMap\n    local inputX  = state._lyricNotesInputX or panel.inputX\n    local inputY  = state._lyricNotesInputY or panel.inputY\n    local lh      = state._lyricNotesInputLineH or 16\n    if not lineMap or #lineMap == 0 then return nil end\n    local li = math.max(1, math.min(#lineMap, math.floor((y - inputY) / lh) + 1))\n    local lineData = lineMap[li]\n    if not lineData then return nil end\n    local relX = x - inputX\n    local lineText = lineData.text\n    -- Lyric notes text is rendered in \"normal\" font; ensure measurement matches.\n    if ui and ui.setMeasureFont then ui.setMeasureFont(\"normal\") end\n    local lo, hi = 0, #lineText\n    while lo < hi do\n        local mid = math.floor((lo + hi + 1) / 2)\n        if ui.measureText(lineText:sub(1, mid)) <= relX then lo = mid else hi = mid - 1 end\n    end\n    return lineData.bufStart + lo\nend\n\nfunction handleLyricNotesPanelMouseDown(x, y, button, screenH)\n    -- Close button (X) in the embedded or detached panel\n    local cb = state._lyricNotesCloseBtn\n    if cb and x >= cb.x and y >= cb.y and x < cb.x + cb.w and y < cb.y + cb.h then\n        state.lyricNotesPanelOpen = false\n        -- Setting detached=false causes the render loop to close the OS window next frame\n        state.lyricNotesDetached = false\n        -- Sync solfege buffer now that the LN panel is gone\n        if not state.solfegeInputActive then\n            state.solfegeInputBuffer = state._solfegeSeqText or state.solfegeInputBuffer\n        end\n        return true\n    end\n    local db = state._lyricNotesDetachBtn\n    if db and x >= db.x and y >= db.y and x < db.x + db.w and y < db.y + db.h then\n        toggleLyricNotesDetached()\n        return true\n    end\n    local panel = state._lyricNotesPanelBounds\n    if not state.lyricNotesPanelOpen or not panel then return false end\n    local inPanel = x >= panel.x and y >= panel.y and x < panel.x + panel.width and y < panel.y + panel.height\n    if not inPanel then return false end\n    if button == 3 then\n        showLyricNotesCtxMenu(x, y, screenH)\n        return true\n    end\n    if x >= panel.inputX and x < panel.inputX + panel.inputW and y >= panel.inputY and y < panel.inputY + panel.inputH then\n        state.lyricNotesInputActive = true\n        state.solfegeInputActive = false  -- transfer focus away from solfege text box\n        state.lyricNotesEditingTokenIndex = nil\n        local clickedPos = lyricNotesTextPosFromPoint(x, y)\n        if clickedPos ~= nil then\n            local buf = state.lyricNotesBuffer or \"\"\n            local newCur = clickedPos >= #buf and nil or clickedPos\n            local posNum = (newCur ~= nil) and newCur or #buf\n            -- Double-click detection: select the word at the clicked position\n            local _now = getCurrentTimeMilliseconds and getCurrentTimeMilliseconds() or (os.clock() * 1000)\n            local _lct = state._lyricNotesLastClickTime or 0\n            local _lcx = state._lyricNotesLastClickX or -999\n            local _lcy = state._lyricNotesLastClickY or -999\n            local _dx, _dy = x - _lcx, y - _lcy\n            local _isDbl = (not _shiftHeld)\n                and (_now - _lct < 400)\n                and (_dx * _dx + _dy * _dy <= 64)\n            state._lyricNotesLastClickTime = _now\n            state._lyricNotesLastClickX = x\n            state._lyricNotesLastClickY = y\n            if _isDbl then\n                local ws, we = findWordBoundsAt0(buf, clickedPos)\n                if ws and we then\n                    state.lyricNotesCursor = (we >= #buf) and nil or we\n                    state.lyricNotesSelAnchor = ws\n                    state.lyricNotesSelFocus  = we\n                    state._lyricNotesDragSel = false  -- don't drag on double-click\n                    return true\n                end\n            end\n            if _shiftHeld and state.lyricNotesSelAnchor ~= nil then\n                -- Extend selection to clicked position\n                state.lyricNotesCursor = newCur\n                state.lyricNotesSelFocus = posNum\n            else\n                -- Normal click: move cursor, set anchor for potential drag-select\n                state.lyricNotesCursor = newCur\n                state.lyricNotesSelAnchor = posNum\n                state.lyricNotesSelFocus  = posNum\n            end\n        else\n            if not _shiftHeld then lyricNotesClearSel() end\n        end\n        state._lyricNotesDragSel = true  -- enable drag-to-select on subsequent mouse moves\n        return true\n    end\n    local tokenBounds = state._lyricNotesTokenBounds\n    if tokenBounds then\n        for _, tokenInfo in ipairs(tokenBounds) do\n            if x >= tokenInfo.x and x < tokenInfo.x + tokenInfo.w and y >= tokenInfo.y and y < tokenInfo.y + tokenInfo.h then\n                lyricTokenDrag.active = true\n                lyricTokenDrag.token = tokenInfo.token\n                lyricTokenDrag.tokenIndex = tokenInfo.index\n                lyricTokenDrag.x = x\n                lyricTokenDrag.y = y\n                lyricTokenDrag.startX = x\n                lyricTokenDrag.startY = y\n                lyricTokenDrag.moved = false\n                lyricTokenDrag.source = \"lyric_notes\"\n                lyricTokenDrag.stepIndex = nil\n                return true\n            end\n        end\n    end\n    return true\nend\n\nfunction dropLyricTokenOnSolfegeInput(token, x, y)\n    if type(token) ~= \"string\" or token == \"\" then\n        return false\n    end\n    local ib = state._solfegeInputBounds\n    if not ib then return false end\n    local inBox = x >= ib.x and y >= ib.y and x < ib.x + ib.w and y < ib.y + ib.h\n    if not inBox then return false end\n\n    local buf = state.solfegeInputBuffer or \"\"\n    -- cursor is 1-indexed; nil means end of buffer\n    local insertPos = state.solfegeInputCursor or (#buf + 1)\n\n    local before = buf:sub(1, insertPos - 1)\n    local after  = buf:sub(insertPos)\n\n    -- Ensure a space separates existing text from the token\n    if #before > 0 and not before:match(\"%s$\") then\n        before = before .. \" \"\n    end\n    if #after > 0 and not after:match(\"^%s\") then\n        after = \" \" .. after\n    end\n\n    local newBuf = before .. token .. after\n    state.solfegeInputBuffer = newBuf\n\n    -- Place cursor just after the inserted token\n    local newPos = #before + #token\n    state.solfegeInputCursor = (newPos >= #newBuf) and nil or (newPos + 1)\n    state.solfegeInputActive = true\n    state.solfegeSelAnchor   = nil\n    state.solfegeSelFocus    = nil\n    state._solfegeLastCursorActivity = os.clock and os.clock() or 0\n    return true\nend\n\nfunction getSyllableDropdownOptions()\n    local scaleStepOptionsByMode = {\n        major         = {0, 2, 4, 5, 7, 9, 11, 12},\n        natural_minor = {0, 2, 3, 5, 7, 8, 10, 12},\n        harmonic_minor= {0, 2, 3, 5, 7, 8, 11, 12},\n        melodic_minor = {0, 2, 3, 5, 7, 9, 11, 12},\n        all           = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},\n    }\n    local scaleSteps = scaleStepOptionsByMode[state.solfegeScale or \"major\"] or scaleStepOptionsByMode.major\n    local baseOctave = state.currentOctave or 4\n    local options = {}\n\n    -- Lower-octave extension: scale steps above Do (not Do itself, not Do') from octave-1\n    if baseOctave > 2 then\n        local lOct = baseOctave - 1\n        for i = #scaleSteps, 1, -1 do\n            local n = scaleSteps[i]\n            if n > 0 and n < 12 then\n                table.insert(options, 1, { note = n, octave = lOct, label = solfegeNotes[n + 1] .. \",\" })\n            end\n        end\n    end\n\n    -- Current octave\n    for _, n in ipairs(scaleSteps) do\n        options[#options + 1] = { note = n, octave = baseOctave, label = solfegeNotes[n + 1] }\n    end\n\n    -- Higher-octave extension: scale steps above Do (not Do itself, not Do') from octave+1\n    if baseOctave < 7 then\n        local hOct = baseOctave + 1\n        for _, n in ipairs(scaleSteps) do\n            if n > 0 and n < 12 then\n                options[#options + 1] = { note = n, octave = hOct, label = solfegeNotes[n + 1] .. \"'\" }\n            end\n        end\n    end\n\n    return options\nend\n\nfunction getCurrentSyllableDropdownValue()\n    local stepData = state.sequence[state.currentStep]\n    if stepData and not core.isChord(stepData) and stepData.lyric == \"_\" then\n        return \"_\", state.currentOctave\n    end\n    if stepData and not core.isChord(stepData) and stepData.note ~= nil then\n        return stepData.note, stepData.octave\n    end\n\n    -- Default to Do for empty steps\n    return 0, state.currentOctave\nend\n\nfunction syncSyllableDropdownSelectionFromCurrent(forceReset)\n    local options = getSyllableDropdownOptions()\n    local existingSelection = state.syllableDropdownSelection\n    if not forceReset and existingSelection and existingSelection >= 1 and existingSelection <= #options then\n        local existingOption = options[existingSelection]\n        state.syllableDropdownSelectedNote = existingOption and existingOption.note or nil\n        return existingSelection, options\n    end\n\n    local currentValue, currentOctave = getCurrentSyllableDropdownValue()\n    local selectedIndex = 1\n    for i, option in ipairs(options) do\n        if option.note == currentValue and (option.octave == nil or option.octave == (currentOctave or state.currentOctave)) then\n            selectedIndex = i\n            break\n        end\n    end\n    state.syllableDropdownSelection = selectedIndex\n    local selOpt = options[selectedIndex]\n    state.syllableDropdownSelectedNote = selOpt and selOpt.note or nil\n    state.syllableDropdownSelectedOctave = selOpt and selOpt.octave or nil\n    if currentValue ~= nil and currentValue ~= \"_\" then\n        state.selectedNote = currentValue\n    end\n    if currentOctave ~= nil then\n        state.currentOctave = currentOctave\n    end\n    return selectedIndex, options\nend\n\nfunction flashSyllableDropdownOctave(delta)\n    state._syllableDropdownOctaveFlashDir = delta\n    state._syllableDropdownOctaveFlashUntil = os.clock() + 0.45\nend\n\nfunction adjustSyllableDropdownOctave(delta)\n    local currentOctave = state.currentOctave or 4\n    local nextOctave = math.max(2, math.min(7, currentOctave + delta))\n    flashSyllableDropdownOctave(delta)\n    if nextOctave == currentOctave then\n        return false\n    end\n    state.currentOctave = nextOctave\n    if not state.isPlaying and state.selectedNote ~= nil and state.selectedNote ~= 13 then\n        playSelectedNotePreview()\n    end\n    return true\nend\n\nfunction moveSyllableDropdownSelection(direction)\n    if not state.syllableDropdownOpen then\n        return false\n    end\n    local selectedIndex, options = syncSyllableDropdownSelectionFromCurrent(false)\n    local count = #options\n    if count < 1 then\n        return false\n    end\n    selectedIndex = selectedIndex + ((direction or 1) >= 0 and 1 or -1)\n    if selectedIndex < 1 then\n        selectedIndex = count\n    elseif selectedIndex > count then\n        selectedIndex = 1\n    end\n    state.syllableDropdownSelection = selectedIndex\n    local selOpt = options[selectedIndex]\n    state.syllableDropdownSelectedNote = selOpt and selOpt.note or nil\n    state.syllableDropdownSelectedOctave = selOpt and selOpt.octave or nil\n    -- Update currentOctave live so preview reflects the right octave\n    if selOpt and selOpt.octave then\n        state.currentOctave = selOpt.octave\n    end\n    if selOpt and selOpt.note ~= nil then\n        state.selectedNote = selOpt.note\n    end\n    return true\nend\n\nfunction applySyllableDropdownChoice(choice)\n    local si = state.currentStep\n    local existingStep = si and state.sequence[si]\n    local chosen = choice\n    local chosenOctave = nil\n    if chosen == nil then\n        local selectedIndex = state.syllableDropdownSelection\n        local renderedButtons = state._syllableDropdownBtns\n        if renderedButtons and selectedIndex and renderedButtons[selectedIndex] then\n            chosen = renderedButtons[selectedIndex].note\n            chosenOctave = renderedButtons[selectedIndex].octave\n        end\n        if chosen == nil then\n            chosen = state.syllableDropdownSelectedNote\n            chosenOctave = state.syllableDropdownSelectedOctave\n        end\n        if chosen == nil then\n            local resolvedIndex, options = syncSyllableDropdownSelectionFromCurrent(false)\n            local selected = options[resolvedIndex]\n            chosen = selected and selected.note or nil\n            chosenOctave = selected and selected.octave or nil\n        end\n    end\n    if chosen == nil or not si then\n        return false\n    end\n\n    local isMelisma = chosen == \"_\"\n    local applyOctave = chosenOctave or state.currentOctave\n    state.syllableDropdownSelectedNote = chosen\n    if existingStep and not core.isChord(existingStep) then\n        recordStepHistoryIfNeeded()\n        if isMelisma then\n            existingStep.lyric = \"_\"\n        else\n            -- Preserve existing lyric; only update the note/octave\n            existingStep.note = chosen\n            existingStep.octave = applyOctave\n            state.selectedNote = chosen\n            state.currentOctave = applyOctave\n            playCurrentStepPreview()\n        end\n        markSequenceDirty()\n    elseif si and not existingStep then\n        recordStepHistoryIfNeeded()\n        if isMelisma then\n            state.sequence[si] = {\n                note = state.selectedNote,\n                octave = applyOctave,\n                lyric = \"_\",\n            }\n        else\n            state.sequence[si] = {\n                note = chosen,\n                octave = applyOctave,\n            }\n            state.selectedNote = chosen\n            state.currentOctave = applyOctave\n            playCurrentStepPreview()\n        end\n        if si > (state.sequenceLength or 0) then\n            state.sequenceLength = si\n            state.sequenceLengths[state.activeSequenceIndex] = si\n        end\n        markSequenceDirty()\n    else\n        return false\n    end\n\n    state.syllableDropdownOpen = false\n    state.syllableDropdownSelection = nil\n    state.syllableDropdownSelectedNote = nil\n    state._syllableDropdownOctaveFlashDir = nil\n    state._syllableDropdownOctaveFlashUntil = nil\n    return true\nend\n\nfunction openSyllableDropdownForStep(stepIdx, col, cellY, grid)\n    local step = state.sequence[stepIdx]\n    state.currentStep = stepIdx\n    local isEmpty = not step or (not core.isChord(step) and step.note == nil)\n    if not isEmpty and step and not core.isChord(step) and step.note ~= nil then\n        state.selectedNote = step.note\n        state.currentOctave = step.octave\n    else\n        -- Empty step: always default to Do\n        state.selectedNote = 0\n    end\n    state.syllableDropdownOpen = true\n    state._syllableDropdownOctaveFlashDir = nil\n    state._syllableDropdownOctaveFlashUntil = nil\n    state.syllableDropdownAnchorX = grid.startX + (col - (grid.gridScrollCol or 0)) * (grid.stepWidth + grid.horizontalGap) + math.floor(grid.stepWidth / 2)\n    state.syllableDropdownAnchorY = cellY + math.floor(grid.stepHeight / 2)\n    state.syllableDropdownStepTop = cellY\n    state.syllableDropdownStepHeight = grid.stepHeight\n    -- Force-reset selection; if empty, directly pin to index 1 (Do)\n    if isEmpty then\n        local options = getSyllableDropdownOptions()\n        state.syllableDropdownSelection = 1\n        state.syllableDropdownSelectedNote = options[1] and options[1].note or 0\n    else\n        syncSyllableDropdownSelectionFromCurrent(true)\n    end\nend\n\nfunction tryInsertStepAt(stepIndex)\n    local seqIndex = state.activeSequenceIndex\n    local seqLen = state.sequenceLength or 0\n    if stepIndex < 1 or stepIndex > (seqLen + 1) or seqLen >= core.maxSteps then\n        return false\n    end\n\n    recordStepHistoryIfNeeded()\n    if not core.insertStep(state, seqIndex, stepIndex, {}) then\n        return false\n    end\n\n    -- Keep playback cursor pointing at the same note after insertion shifts indices\n    if state.isPlaying and stepIndex <= state.currentPlaybackStep then\n        state.currentPlaybackStep = state.currentPlaybackStep + 1\n        state.playbackPosition = state.currentPlaybackStep\n    end\n\n    -- In lyrics mode the text buffer is the source of truth. Insert a \"--\" placeholder\n    -- at word position stepIndex so the buffer has an explicit empty slot for the new step.\n    -- Without this, markSequenceDirty (now skipping buffer overwrite in lyrics mode)\n    -- would leave the buffer unchanged and liveApplySequenceFromText would later ignore\n    -- the gap, losing the last shifted lyric.\n    if state.solfegeTextMode == \"lyrics\" then\n        local buf = state.solfegeInputBuffer or \"\"\n        local count = 0\n        local i = 1\n        local insertAt = nil\n        while i <= #buf do\n            local ws, we = buf:find(\"%S+\", i)\n            if not ws then break end\n            count = count + 1\n            if count == stepIndex then\n                insertAt = ws\n                break\n            end\n            i = we + 1\n        end\n        if insertAt then\n            local before = buf:sub(1, insertAt - 1)\n            local after = buf:sub(insertAt)\n            if before ~= \"\" and not before:match(\"[%s\\n]$\") then before = before .. \" \" end\n            state.solfegeInputBuffer = before .. \"-- \" .. after\n        else\n            local sep = (buf ~= \"\" and not buf:match(\"[%s\\n]$\")) and \" \" or \"\"\n            state.solfegeInputBuffer = buf .. sep .. \"--\"\n        end\n        _requestLiveApply(true)\n    end\n\n    markSequenceDirty()\n    state.currentStep = stepIndex\n\n    local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n    if grid then\n        local row = math.floor((stepIndex - 1) / grid.stepsPerRow)\n        local col = (stepIndex - 1) % grid.stepsPerRow\n        local cellY = grid.startY + row * (grid.stepHeight + grid.verticalGap)\n        openSyllableDropdownForStep(stepIndex, col, cellY, grid)\n    end\n\n    return true\nend\n\nfunction setSelectedNoteFromPreviousSequenceStep(stepIndex)\n    local previousStepIndex = (stepIndex or state.currentStep or 1) - 1\n    while previousStepIndex >= 1 do\n        local previousStep = state.sequence[previousStepIndex]\n        if previousStep then\n            if core.isChord(previousStep) then\n                local firstNote = previousStep.notes and previousStep.notes[1]\n                if firstNote and firstNote.note ~= nil then\n                    state.selectedNote = firstNote.note\n                    state.currentOctave = firstNote.octave or state.currentOctave\n                    return true\n                end\n            elseif previousStep.note ~= nil and previousStep.note ~= 13 then\n                state.selectedNote = previousStep.note\n                state.currentOctave = previousStep.octave or state.currentOctave\n                return true\n            end\n        end\n        previousStepIndex = previousStepIndex - 1\n    end\n    return false\nend\n\nfunction activateStepSelection(stepIdx, wasCurrentStep)\n    local si = stepIdx\n    if not si then\n        return false\n    end\n\n    breakEditCursorFollow()\n    local stepData = state.sequence[si]\n    state.currentStep = si\n    if stepData then\n        if core.isChord(stepData) then\n            state.chordEditIndex = 1\n            local firstNote = stepData.notes[1]\n            if firstNote then\n                state.selectedNote = firstNote.note\n                state.currentOctave = firstNote.octave\n            end\n        elseif stepData.note ~= nil then\n            state.selectedNote = stepData.note\n            state.currentOctave = stepData.octave\n        else\n            local defaultNote, defaultOctave = getCurrentSyllableDropdownValue()\n            if defaultNote ~= nil and defaultNote ~= \"_\" then\n                state.selectedNote = defaultNote\n            end\n            if defaultOctave ~= nil then\n                state.currentOctave = defaultOctave\n            end\n        end\n    else\n        local defaultNote, defaultOctave = getCurrentSyllableDropdownValue()\n        if defaultNote ~= nil and defaultNote ~= \"_\" then\n            state.selectedNote = defaultNote\n        end\n        if defaultOctave ~= nil then\n            state.currentOctave = defaultOctave\n        end\n    end\n    if not state.isPlaying then\n        if stepData then\n            playCurrentStepPreview()\n        end\n    end\n    _syncKeyLineInTextBuffer()\n\n    if wasCurrentStep and not (stepData and core.isChord(stepData)) then\n        if state.showSolfegeTextInput ~= false\n           and (state.solfegeTextMode or \"both\") == \"steps\" then\n            local buf = state.solfegeInputBuffer or \"\"\n            local tokS, tokE = _solfegeStepTokenRange(buf, si)\n            if tokS then\n                state.solfegeInputActive = true\n                state.solfegeInputCursor = tokE\n                state.solfegeSelAnchor = tokS - 1\n                state.solfegeSelFocus = tokE\n                state._solfegeLastCursorActivity = os.clock and os.clock() or 0\n                if not state._lyricsSnapshot then\n                    local snap = {}\n                    for i = 1, #(state.sequence or {}) do\n                        if state.sequence[i] then snap[i] = state.sequence[i].lyric end\n                    end\n                    state._lyricsSnapshot = snap\n                end\n            end\n        else\n            local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n            if grid then\n                local scol = (si - 1) % grid.stepsPerRow\n                local srow = math.floor((si - 1) / grid.stepsPerRow)\n                local cellY = grid.startY + srow * (grid.stepHeight + grid.verticalGap)\n                openSyllableDropdownForStep(si, scol, cellY, grid)\n            end\n        end\n    end\n\n    return true\nend\n\nfunction exportMusicXML(filename)\n    syncActiveSequenceState()\n    local ok, errorMessage = MusicXML.exportMusicXMLFile(state, core, filename, writeTextFile)\n    if ok then\n        showSaveMessage = true\n        saveMessageTimer = 90\n        return true\n    end\n    print(\"MusicXML export failed: \" .. tostring(errorMessage))\n    return false\nend\n\nfunction shellQuotePath(path)\n    if type(path) ~= \"string\" then\n        return nil\n    end\n\n    return \"'\" .. path:gsub(\"'\", \"'\\\\''\") .. \"'\"\nend\n\n_bkShellQuote = shellQuotePath\n\nfunction repoLocalPath(filename)\n    local source = debug.getinfo(1, \"S\").source or \"\"\n    local dir = source:match(\"@?(.*/)\")\n    if not dir or dir == \"\" then\n        dir = \"./\"\n    end\n    return dir .. filename\nend\n\nfunction curwenEventFilePath()\n    return \"/tmp/solfege-curwen-events.txt\"\nend\n\nfunction noteIndexForCurwenSyllable(syllable)\n    local target = tostring(syllable or \"\"):lower()\n    local notes = state.solfegeNotes or core.getSolfegeNotes(state.solfegeScale or \"major\")\n    for i, noteName in ipairs(notes) do\n        local normalized = tostring(noteName or \"\"):lower():gsub(\"'\", \"\")\n        if normalized == target then\n            return i - 1\n        end\n    end\n    local fallback = {[\"do\"] = 0, re = 2, mi = 4, fa = 5, sol = 7, la = 9, ti = 11}\n    return fallback[target]\nend\n\nfunction enterCurwenSyllable(syllable)\n    local noteIndex = noteIndexForCurwenSyllable(syllable)\n    if noteIndex == nil then\n        return false\n    end\n    if state.singSolfegeMode then\n        return false\n    end\n\n    breakEditCursorFollow()\n    state.selectedNote = noteIndex\n    if state.currentOctave == nil then\n        state.currentOctave = 4\n    end\n    if applySyllableDropdownChoice(noteIndex) then\n        local maxStep = math.min(core.maxSteps, (state.sequenceLength or 0) + 1)\n        state.currentStep = math.min((state.currentStep or 1) + 1, maxStep)\n        showImportMessage = true\n        importMessageTimer = 45\n        importMessageText = \"Curwen: \" .. tostring(syllable)\n        return true\n    end\n    return false\nend\n\nfunction addCurwenSignToChat(syllable, entered)\n    state.cmdChatMessages = state.cmdChatMessages or {}\n    if #state.cmdChatMessages == 0 then\n        table.insert(state.cmdChatMessages, cmdChat.welcomeMessage())\n    end\n\n    local stepLabel = tostring(state.currentStep or 1)\n    local text\n    if entered then\n        text = \"Webcam Curwen sign: \" .. tostring(syllable) .. \" entered at step \" .. stepLabel\n    else\n        text = \"Webcam Curwen sign: \" .. tostring(syllable)\n    end\n\n    table.insert(state.cmdChatMessages, {role = \"system\", text = text})\n    while #state.cmdChatMessages > 100 do\n        table.remove(state.cmdChatMessages, 1)\n    end\n    state.cmdChatOpen = true\n    state.cmdChatInputActive = false\n    state.cmdChatScrollOffset = 0\nend\n\nfunction pollCurwenEvents()\n    if platformAdapters.name ~= \"sdl\" then\n        return\n    end\n    local path = curwenEventFilePath()\n    local f = io.open(path, \"r\")\n    if not f then\n        return\n    end\n    local content = f:read(\"*a\") or \"\"\n    f:close()\n    if content == \"\" then\n        return\n    end\n\n    local latestKey = nil\n    local latestSyllable = nil\n    for line in content:gmatch(\"[^\\r\\n]+\") do\n        local stamp, syllable = line:match(\"^(%d+)%s+([A-Za-z]+)\")\n        if stamp and syllable then\n            latestKey = stamp .. \":\" .. syllable\n            latestSyllable = syllable\n        end\n    end\n    if not state._curwenLastEventKey then\n        state._curwenLastEventKey = latestKey\n        return\n    end\n    if latestKey and latestKey ~= state._curwenLastEventKey then\n        state._curwenLastEventKey = latestKey\n        local entered = enterCurwenSyllable(latestSyllable)\n        addCurwenSignToChat(latestSyllable, entered)\n    end\nend\n\nfunction openWebcamWindow()\n    if isRunningOnMacDesktop() then\n        local sourcePath = repoLocalPath(\"tools/webcam_viewer.swift\")\n        local appPath = repoLocalPath(\"tools/SolfegeWebcam.app\")\n        local binPath = appPath .. \"/Contents/MacOS/SolfegeWebcam\"\n        local sourceFile = io.open(sourcePath, \"r\")\n        if not sourceFile then\n            print(\"Webcam window failed: missing \" .. tostring(sourcePath))\n            showImportMessage = true\n            importMessageTimer = 180\n            importMessageText = \"Webcam helper missing\"\n            return false\n        end\n        sourceFile:close()\n\n        local clearFile = io.open(curwenEventFilePath(), \"w\")\n        if clearFile then\n            clearFile:close()\n        end\n\n        local quotedApp = shellQuotePath(appPath)\n        local quotedBin = shellQuotePath(binPath)\n        local quotedSource = shellQuotePath(sourcePath)\n        if not quotedApp or not quotedBin or not quotedSource then\n            return false\n        end\n\n        local binFile = io.open(binPath, \"r\")\n        if binFile then\n            binFile:close()\n        else\n            local buildCommand = \"mkdir -p \" .. shellQuotePath(appPath .. \"/Contents/MacOS\") ..\n                \" && /usr/bin/swiftc \" .. quotedSource ..\n                \" -o \" .. quotedBin ..\n                \" -framework AppKit -framework AVFoundation -framework Vision\"\n            local buildStatus = os.execute(buildCommand)\n            if not (buildStatus == true or buildStatus == 0) then\n                print(\"Webcam helper build failed: \" .. tostring(buildStatus))\n                showImportMessage = true\n                importMessageTimer = 180\n                importMessageText = \"Webcam build failed\"\n                return false\n            end\n        end\n\n        local status = os.execute(\"open \" .. quotedApp)\n        if status == true or status == 0 then\n            showImportMessage = true\n            importMessageTimer = 120\n            importMessageText = \"Opened webcam\"\n            return true\n        end\n\n        print(\"Webcam window failed: command exited with status \" .. tostring(status))\n        showImportMessage = true\n        importMessageTimer = 180\n        importMessageText = \"Could not open webcam\"\n        return false\n    end\n\n    local pagePath = repoLocalPath(\"webcam_window.html\")\n    local quotedPage = shellQuotePath(pagePath)\n    if not quotedPage then\n        return false\n    end\n\n    local status = os.execute(\"xdg-open \" .. quotedPage)\n    if status == true or status == 0 then\n        showImportMessage = true\n        importMessageTimer = 120\n        importMessageText = \"Opened webcam\"\n        return true\n    end\n\n    print(\"Webcam window failed: command exited with status \" .. tostring(status))\n    showImportMessage = true\n    importMessageTimer = 180\n    importMessageText = \"Could not open webcam\"\n    return false\nend\n\nfunction openFolderInFinder(folderPath)\n    if not folderPath or folderPath == \"\" then return end\n    os.execute(\"open \" .. shellQuotePath(folderPath))\nend\n\nfunction openCurrentMusicXMLFile()\n    local targetFile = tostring(autoSaveMusicXMLFilename or \"\")\n    if targetFile == \"\" then\n        return false\n    end\n\n    syncActiveSequenceState()\n    local xmlSaved, xmlError = MusicXML.exportMusicXMLFile(state, core, targetFile, writeTextFile)\n    if not xmlSaved then\n        print(\"Open MusicXML failed: \" .. tostring(xmlError))\n        return false\n    end\n\n    local quoted = shellQuotePath(targetFile)\n    if not quoted then\n        return false\n    end\n\n    local openCommand = \"\"\n    if isRunningOnMacDesktop() then\n        openCommand = \"open -R \" .. quoted\n    else\n        openCommand = \"xdg-open \" .. quoted\n    end\n\n    local status = os.execute(openCommand)\n    if status == true or status == 0 then\n        showImportMessage = true\n        importMessageTimer = 120\n        importMessageText = \"✓ Opened MusicXML\"\n        return true\n    end\n\n    print(\"Open MusicXML failed: command exited with status \" .. tostring(status))\n    return false\nend\n\nfunction exportDocxLyrics(filename)\n    local ok, result = LyricsImport.exportDocxLyricsFile(state, filename)\n    if ok then\n        showSaveMessage = true\n        saveMessageTimer = 90\n        return true\n    end\n    print(\"DOCX lyrics export failed: \" .. tostring(result))\n    return false\nend\n\nfunction exportPlainTextLyrics(filename)\n    local ok, result = LyricsImport.exportPlainTextLyricsFile(state, filename)\n    if ok then\n        showSaveMessage = true\n        saveMessageTimer = 90\n        return true\n    end\n    print(\"Plain text lyrics export failed: \" .. tostring(result))\n    return false\nend\n\nfunction exportLyricCreatorLyrics(filename)\n    local ok, result = LyricsImport.exportLyricCreatorFile(state, filename)\n    if ok then\n        showSaveMessage = true\n        saveMessageTimer = 90\n        return true\n    end\n    print(\"Lyric Creator export failed: \" .. tostring(result))\n    return false\nend\n\nlocal function buildMusicXMLSaveAsFilename()\n    local timestamp = os.date(\"%Y%m%d-%H%M%S\")\n    local pkgPath = resolveMusicXMLStoragePath(\"export-\" .. tostring(timestamp) .. \".solfege\")\n    return pkgXMLFilename(pkgPath)\nend\n\nfunction buildAudioExportFilename(extension)\n    local timestamp = os.date(\"%Y%m%d-%H%M%S\")\n    return resolveMusicXMLStoragePath(\"export-audio-\" .. tostring(timestamp) .. \".\" .. (extension or \"wav\"))\nend\n\nfunction exportAudioWav(filename)\n    syncActiveSequenceState()\n    local ok, wavData = AudioExport.renderActiveSequenceWav({\n        state = state,\n        core = core,\n        noteFreqs = core.noteFreqs,\n        transposeNoteForKey = transposeNoteForKey,\n        isMacDesktop = isRunningOnMacDesktop(),\n    })\n    if not ok then\n        print(\"WAV export failed: \" .. tostring(wavData))\n        return false\n    end\n    local success, err = writeBinaryFile(filename, wavData)\n    if success then\n        showSaveMessage = true\n        saveMessageTimer = 90\n        return true\n    end\n    print(\"WAV file write failed: \" .. tostring(err))\n    return false\nend\n\nfunction exportAudioMp3(filename)\n    local wavFilename = filename:gsub(\"%.mp3$\", \".wav\")\n    if not exportAudioWav(wavFilename) then\n        return false\n    end\n    local cmd = string.format('ffmpeg -y -i \"%s\" \"%s\" 2>/dev/null', wavFilename, filename)\n    local result = os.execute(cmd)\n    if result == 0 or result == true then\n        os.remove(wavFilename)\n        showSaveMessage = true\n        saveMessageTimer = 90\n        return true\n    end\n    print(\"MP3 conversion failed (ffmpeg not found?). WAV saved at: \" .. tostring(wavFilename))\n    return false\nend\n\nlocal function buildNewProjectMusicXMLFilename()\n    -- Find the highest existing project-N.solfege (or legacy project-N.musicxml) number and use N+1.\n    local highest = 0\n    local pipe = io.popen(\"ls \\\"\" .. musicXMLStorageDirectory .. \"\\\" 2>/dev/null\", \"r\")\n    if pipe then\n        for name in pipe:lines() do\n            local n = tonumber(name:match(\"^project%-(%d+)%.solfege$\"))\n                   or tonumber(name:match(\"^project%-(%d+)%.musicxml$\"))\n            if n and n > highest then highest = n end\n        end\n        pipe:close()\n    end\n    local pkgPath = resolveMusicXMLStoragePath(\"project-\" .. tostring(highest + 1) .. \".solfege\")\n    return pkgXMLFilename(pkgPath)\nend\n\nlocal function clearAllPatternsForNewProject()\n    if state.isPlaying or state.isPaused then\n        stopPlayback()\n    end\n\n    core.syncActiveSequenceState(state)\n\n    state.sequences = {}\n    state.sequenceLengths = {}\n    state.sequenceMutes = {}\n    state.sequenceOctaveTranspose = {}\n\n    for i = 1, core.maxSequences do\n        state.sequences[i] = {}\n        state.sequenceLengths[i] = 0\n        state.sequenceMutes[i] = false\n        state.sequenceOctaveTranspose[i] = 0\n    end\n\n    state.activeSequenceIndex = 1\n    state.patternTargetSequenceIndex = 1\n    state.currentPlaybackSequenceIndex = 1\n    state.sequence = state.sequences[1]\n    state.sequenceLength = 0\n    state.currentStep = 1\n    state.playbackPosition = 1\n    state.currentPlaybackStep = 1\n\n    -- Reset UI interaction state so steps work correctly in the new project\n    -- regardless of what mode the app was in before clicking New.\n    state.editMode = \"note\"\n    state.multiSelectMode = false\n    state.selectedSteps = {}\n    state.muteMode = false\n    state.gateMode = false\n    state.lyricNotesPanelOpen = false\n    state.lyricEditingStepIndex = nil\n    state.syllableDropdownOpen = false\n    state.stepContextMenu = nil\n    state.barEditing = false\n    state.beatEditing = false\n    state.seekEditing = false\n    state.musicXMLFilenameEditing = false\n    state.lyricNotesInputActive = false\n    state.lyricNotesEditingTokenIndex = nil\n    state.linkedLyricsDocxPath = nil\n    -- Cancel any in-progress drag/hold operations\n    holdPending.active = false\n    holdPending.stepIndex = nil\n    holdPending.startTime = nil\n    holdPending.startX = nil\n    holdPending.startY = nil\n    stretchDrag.active = false\n    stretchDrag.previewLen = nil\n    gateDrag.active = false\n    reorderDrag.active = false\n    selectDrag.active = false\n    selectDrag.startIndex = nil\n    lyricTokenDrag.active = false\n\n    clearPitchRecognitionResults()\n    clearPitchHistory()\n    resetStepHistory()\n    resetSingSolfegeOctaveOffset()\nend\n\nlocal function createNewProject()\n    -- Flush any pending auto-save for the current project before clearing,\n    -- so notes added within the last 5-frame (83ms) window are not lost.\n    if type(saveSequence) == \"function\" then\n        saveSequence(false)\n    end\n    -- Delete the default export.solfege package on first new project creation\n    local defaultPkg = resolveMusicXMLStoragePath(\"export.solfege\")\n    local defaultExport = pkgXMLFilename(defaultPkg)\n    if autoSaveMusicXMLFilename == defaultExport then\n        os.execute(\"rm -rf \" .. _bkShellQuote(defaultPkg))\n    end\n    clearAllPatternsForNewProject()\n    enterLyricsScreen({refreshBuffer = true})\n    autoSaveMusicXMLFilename = buildNewProjectMusicXMLFilename()\n    -- Display name: package directory name (e.g. \"project-1.solfege\")\n    local newPkg = autoSaveMusicXMLFilename:match(\"^(.+%.solfege)/[^/]+%.musicxml$\")\n    state.musicXMLFileName = newPkg and basename(newPkg) or basename(autoSaveMusicXMLFilename)\n\n    local _dbg = io.open(\"/tmp/solfege_debug.log\", \"a\")\n    if _dbg then\n        local _cwd = (function() local p = io.popen(\"pwd\",\"r\"); if p then local v=p:read(\"*l\"); p:close(); return v end end)() or \"?\"\n        _dbg:write(string.format(\"[createNewProject] new file=%s cwd=%s time=%s\\n\",\n            tostring(autoSaveMusicXMLFilename), _cwd, os.date(\"%H:%M:%S\")))\n        _dbg:close()\n    end\n\n    local xmlSaved, xmlError = MusicXML.exportMusicXMLFile(state, core, autoSaveMusicXMLFilename, writeTextFile)\n    if not xmlSaved then\n        print(\"New project MusicXML creation failed: \" .. tostring(xmlError))\n        return false\n    end\n\n    markSequenceDirty()\n    showImportMessage = true\n    importMessageTimer = 120\n    importMessageText = \"✓ New Project\"\n    return true\nend\n\n-- Opens a .docx file as a brand-new project: clears the current project,\n-- creates a fresh save file, then imports the docx text as lyrics.\nfunction openDocxAsNewProject(filename)\n    closeWelcomeScreen()\n    createNewProject()\n    state.linkedLyricsDocxPath = filename\n    state.solfegeTextMode = \"lyrics\"\n    state.showSolfegeTextInput = true\n    importDocxLyrics(filename)\nend\n\nlocal function findMostRecentMusicXMLFile()\n    local baseDir = shellQuotePath(musicXMLStorageDirectory)\n    -- Prefer .solfege packages; fall back to legacy .musicxml/.xml files\n    local cmd = \"ls -1dt \" .. baseDir .. \"/*.solfege \" .. baseDir .. \"/*.musicxml \" .. baseDir .. \"/*.xml 2>/dev/null\"\n    local pipe = io.popen(cmd, \"r\")\n    if not pipe then\n        return nil\n    end\n\n    local firstPath = pipe:read(\"*l\")\n    pipe:close()\n\n    if firstPath and firstPath ~= \"\" then\n        return firstPath\n    end\n\n    return nil\nend\n\nfunction findRecentMusicXMLFiles(maxCount)\n    local baseDir = shellQuotePath(musicXMLStorageDirectory)\n    -- List .solfege packages and legacy .musicxml/.xml files sorted by modification time\n    local cmd = \"ls -1dt \" .. baseDir .. \"/*.solfege \" .. baseDir .. \"/*.musicxml \" .. baseDir .. \"/*.xml 2>/dev/null\"\n    local pipe = io.popen(cmd, \"r\")\n    if not pipe then return {} end\n    local results = {}\n    for line in pipe:lines() do\n        if line and line ~= \"\" then\n            local base = line:match(\"([^/]+)$\") or line\n            -- Strip .solfege, .musicxml, or .xml for display\n            local name = base:gsub(\"%.solfege$\", \"\"):gsub(\"%.[^%.]+$\", \"\"):gsub(\"[_%-]+\", \" \"):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n            local folder = line:match(\"(.+)/[^/]+$\") or \"\"\n            table.insert(results, {path = line, name = name, folder = folder})\n            if #results >= maxCount then break end\n        end\n    end\n    pipe:close()\n    return results\nend\n\nfunction findBackupFiles(maxCount)\n    local bkDir = getBackupsDirectory()\n    if not bkDir then return {} end\n    local qdir = shellQuotePath(bkDir)\n    local pipe = io.popen(\"ls -1t \" .. qdir .. \"/sequence_*.musicxml 2>/dev/null\", \"r\")\n    if not pipe then return {} end\n    local results = {}\n    for line in pipe:lines() do\n        if line and line ~= \"\" then\n            local base = line:match(\"([^/]+)$\") or line\n            table.insert(results, {path = line, label = backupFileLabel(base)})\n            if #results >= maxCount then break end\n        end\n    end\n    pipe:close()\n    return results\nend\n\nfunction revertToBackup(path)\n    if not path or path == \"\" then return end\n    do local _d=io.open(\"/tmp/solfege_debug.log\",\"a\"); if _d then _d:write(string.format(\"[revertToBackup] path=%s activeSave=%s time=%s\\n\",tostring(path),tostring(autoSaveMusicXMLFilename),os.date(\"%H:%M:%S\"))); _d:close() end end\n    -- Use the low-level importer directly so we don't overwrite autoSaveMusicXMLFilename\n    -- (importMusicXML would redirect future saves into the backup file).\n    local ok, err = MusicXML.importMusicXMLFile(state, core, path, readTextFile)\n    if ok then\n        resetStepHistory()\n        if state.isPlaying then stopPlayback() end\n        setTempo(state.tempo or state.defaultTempo)\n        setStepBeats(state.stepBeats or 1)\n        local _cs = state.sequence[state.currentStep]\n        if _cs then\n            if core.isChord(_cs) then\n                local fn = _cs.notes and _cs.notes[1]\n                if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n            elseif _cs.note ~= nil and _cs.note ~= 13 then\n                state.selectedNote = _cs.note\n                state.currentOctave = _cs.octave\n            end\n        else\n            state.selectedNote = 0\n        end\n        -- Preserve the buffer/mode restored from the backup by applyMetadataToState.\n        -- markSequenceDirty() would overwrite solfegeInputBuffer with a re-serialization\n        -- of the sequence (which may be empty), so we save and re-apply them after.\n        local _savedBuf = state.solfegeInputBuffer\n        local _savedMode = state.solfegeTextMode\n        state._solfegeSeqText = serializeSequenceToText()\n        state.solfegeInputBuffer = state._solfegeSeqText\n        markSequenceDirty()\n        -- Re-apply saved buffer when the sequence serializes to nothing (e.g. lyrics-only text)\n        if _savedBuf and _savedBuf ~= \"\" and (state.solfegeInputBuffer == \"\" or state.solfegeInputBuffer == nil) then\n            state.solfegeInputBuffer = _savedBuf\n            state._solfegeSeqText = _savedBuf\n        end\n        if _savedMode then state.solfegeTextMode = _savedMode end\n        showSaveMessage = true\n        saveMessageTimer = 60\n    end\nend\n\nfunction closeWelcomeScreen()\n    showWelcomeScreen = false\n    welcomeRecentFiles = {}\n    state._welcomeBtns = nil\n    state._welcomeFirstVisible = 1\n    state._welcomeVisibleCount = nil\n    state._welcomeScrollbar = nil\n    welcomeScrollDrag.active = false\nend\n\nfunction openProjectFromWelcome(path)\n    closeWelcomeScreen()\n    importMusicXML(path)\nend\n\nfunction newProjectFromWelcome()\n    closeWelcomeScreen()\n    createNewProject()\nend\n\nfunction getOneDriveWelcomeRoot()\n    local od = rawget(_G, \"_oneDriveBridge\")\n    if od and od.isAvailable and od.isAvailable() then\n        if od.isSignedIn() then\n            return od.getUserName() or \"OneDrive\"\n        end\n        return nil\n    end\n    if _oneDriveWelcomeRoot ~= nil then\n        return _oneDriveWelcomeRoot ~= false and _oneDriveWelcomeRoot or nil\n    end\n    local root = (package.loaded[\"onedrive_paths\"] or import \"onedrive_paths\").resolveRoot()\n    _oneDriveWelcomeRoot = root or false\n    return root\nend\n\nfunction saveAppState()\n    saveSequence(false)\n    -- Write immediately (bypass debounce) so pending preferences aren't lost on exit/pause.\n    _prefSavePendingAt = nil\n    storage.savePreferences(storageAdapter.write, preferencesFilename, state)\nend\n\n-- Convert solfege note index (0=C4..11=B4,12=C5) + octave to MIDI note number\nfunction toMidiNote(noteIndex, octave)\n    -- noteIndex 0-11 are chromatic pitches relative to C4; 12 wraps to C5\n    local semitone = noteIndex % 12\n    local noteOctave = octave + math.floor(noteIndex / 12)\n    return 12 + noteOctave * 12 + semitone  -- MIDI: C-1=0, C4=60\nend\n\nlocal function sendMidiNoteOff()\n    if not midiOut then return end\n    if _dialCC.lastNote then\n        midiOut.noteOff(1, _dialCC.lastNote)\n        _dialCC.lastNote = nil\n    end\n    _dialCC.noteOffAt = nil\nend\n\nlocal function sendMidiNoteOn(noteIndex, octave, stepIndex, noteDurationSeconds)\n    if not state.midiOutEnabled or not midiOut then return end\n    if state.audioMuted then return end\n    local midiNote = toMidiNote(noteIndex, octave)\n    local velocity = 100\n    if state.isPlaying then\n        local velocityStep = stepIndex or state.playbackPosition\n        if velocityStep % 4 == 1 then\n            velocity = 100\n        elseif velocityStep % 4 == 3 then\n            velocity = 85\n        else\n            velocity = 90\n        end\n    end\n    sendMidiNoteOff()\n    midiOut.noteOn(1, midiNote, velocity)\n    _dialCC.lastNote = midiNote\n    if noteDurationSeconds and noteDurationSeconds > 0 then\n        _dialCC.noteOffAt = getCurrentTimeMilliseconds() + math.floor(noteDurationSeconds * 1000)\n    end\nend\n\n-- Ordered list of actions that can be mapped to MIDI controls.\nlocal MIDI_ACTIONS = {\n    { id = \"play_stop\",    label = \"Play / Stop\" },\n    { id = \"loop_toggle\",  label = \"Loop Toggle\" },\n    { id = \"next_step\",    label = \"Next Step\" },\n    { id = \"prev_step\",    label = \"Prev Step\" },\n    { id = \"octave_up\",    label = \"Octave +\" },\n    { id = \"octave_down\",  label = \"Octave -\" },\n    { id = \"bpm_up\",       label = \"BPM Up\" },\n    { id = \"bpm_down\",     label = \"BPM Down\" },\n    { id = \"tempo_dial\",    label = \"Tempo Dial (CC)\" },\n    { id = \"step_buttons\",  label = \"Step Buttons (1-16)\" },\n    { id = \"pattern_next\",  label = \"Pattern Next\" },\n    { id = \"pattern_prev\",  label = \"Pattern Prev\" },\n    { id = \"pitch_dial\",    label = \"Pitch Dial (CC)\" },\n}\n\n-- Persistent state for tempo dial CC tracking\n-- Persistent state for MIDI CC dial tracking (tempo and pitch dials).\n-- Grouped into one table to stay under Lua's 200 local-variable limit.\nlocal _dialCC = {\n    lastBPM   = nil, bpmAccum  = 0, BPM_UNITS = 3,   -- tempo dial\n    lastPitch = nil, pitchAccum = 0, OCT_UNITS = 10,  -- pitch dial\n    lastNote  = nil, noteOffAt = nil,                  -- MIDI note-off tracking\n}\n\n-- Execute a mapped MIDI action by id.\nlocal function triggerMidiAction(action)\n    if action == \"play_stop\" then\n        if state.isPlaying then stopPlayback() else startPlayback() end\n    elseif action == \"loop_toggle\" then\n        state.loopPlayback = not state.loopPlayback\n        _syncKeyLineInTextBuffer()\n    elseif action == \"next_step\" then\n        breakEditCursorFollow()\n        state.currentStep = state.currentStep + 1\n        if state.currentStep > state.sequenceLength + 1 then state.currentStep = 1 end\n        local _cs = state.sequence[state.currentStep]\n        if not _cs or (not core.isChord(_cs) and _cs.note == nil) then\n            state.selectedNote = 0\n        elseif core.isChord(_cs) then\n            local fn = _cs.notes and _cs.notes[1]\n            if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n        elseif _cs.note ~= nil and _cs.note ~= 13 then\n            state.selectedNote = _cs.note\n            state.currentOctave = _cs.octave\n        end\n        do\n            local spr = state._gridStepsPerRow or 1\n            local vc  = state._gridVisibleCols  or 1\n            local col = (state.currentStep - 1) % spr\n            local sc  = state.gridScrollCol or 0\n            if col < sc then state.gridScrollCol = col\n            elseif col >= sc + vc then state.gridScrollCol = col - vc + 1 end\n        end\n    elseif action == \"prev_step\" then\n        breakEditCursorFollow()\n        state.currentStep = state.currentStep - 1\n        if state.currentStep < 1 then state.currentStep = math.max(1, state.sequenceLength) end\n        local _cs = state.sequence[state.currentStep]\n        if not _cs or (not core.isChord(_cs) and _cs.note == nil) then\n            state.selectedNote = 0\n        elseif core.isChord(_cs) then\n            local fn = _cs.notes and _cs.notes[1]\n            if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n        elseif _cs.note ~= nil and _cs.note ~= 13 then\n            state.selectedNote = _cs.note\n            state.currentOctave = _cs.octave\n        end\n        do\n            local spr = state._gridStepsPerRow or 1\n            local vc  = state._gridVisibleCols  or 1\n            local col = (state.currentStep - 1) % spr\n            local sc  = state.gridScrollCol or 0\n            if col < sc then state.gridScrollCol = col\n            elseif col >= sc + vc then state.gridScrollCol = col - vc + 1 end\n        end\n    elseif action == \"octave_up\" then\n        shiftPatternOctave(1)\n    elseif action == \"octave_down\" then\n        shiftPatternOctave(-1)\n    elseif action == \"bpm_up\" then\n        adjustDefaultTempo(1)\n    elseif action == \"bpm_down\" then\n        adjustDefaultTempo(-1)\n    elseif action == \"pattern_next\" or action == \"pattern_prev\" then\n        -- Cycle through populated patterns (non-nil sequences), wrapping around\n        local maxSeq = core.maxSequences\n        local cur = state.activeSequenceIndex\n        local step = (action == \"pattern_next\") and 1 or -1\n        for _ = 1, maxSeq - 1 do\n            cur = ((cur - 1 + step) % maxSeq) + 1\n            if state.sequences[cur] then\n                core.setActiveSequence(state, cur)\n                markSequenceDirty()\n                break\n            end\n        end\n    end\nend\n\n-- Process incoming MIDI notes from external keyboards/apps each frame.\n-- Converts MIDI note numbers to solfege, plays audio, and writes to the\n-- current sequence step (when not playing and no menu is open).\n-- Also processes CC events through the midiMappings table.\nlocal function tickMidiInput()\n    if not midiOut or not midiOut.pollInput then return end\n    local events = midiOut.pollInput()\n    if not events then return end\n    for _, ev in ipairs(events) do\n        -- MIDI learn: capture next noteOn or CC (value > 0) as a new mapping\n        if state.midiLearnMode and state.midiLearnTarget then\n            local captured = false\n            if ev.type == \"noteOn\" then\n                state.midiMappings[state.midiLearnTarget] = { type = \"note\", number = ev.note }\n                captured = true\n            elseif ev.type == \"cc\" and ev.velocity > 0 then\n                state.midiMappings[state.midiLearnTarget] = { type = \"cc\", number = ev.note }\n                captured = true\n            end\n            if captured then\n                state.midiLearnMode = false\n                state.midiLearnTarget = nil\n                savePreferences()\n            end\n            -- Don't process this event further while learning\n            goto continue\n        end\n\n        -- Check if event matches a MIDI control mapping\n        local actionTriggered = false\n        for _, act in ipairs(MIDI_ACTIONS) do\n            local mapping = state.midiMappings[act.id]\n            if mapping then\n                -- Tempo dial: delta-tracking CC (fires on any CC value, not just > 0)\n                if act.id == \"tempo_dial\" and mapping.type == \"cc\"\n                   and ev.type == \"cc\" and ev.note == mapping.number then\n                    local newVal = ev.velocity\n                    if _dialCC.lastBPM ~= nil then\n                        local delta = newVal - _dialCC.lastBPM\n                        -- Ignore wrap-around jumps (> half of 0-127 range)\n                        if math.abs(delta) <= 64 then\n                            _dialCC.bpmAccum = _dialCC.bpmAccum + delta\n                            while _dialCC.bpmAccum >= _dialCC.BPM_UNITS do\n                                adjustDefaultTempo(1)\n                                _dialCC.bpmAccum = _dialCC.bpmAccum - _dialCC.BPM_UNITS\n                            end\n                            while _dialCC.bpmAccum <= -_dialCC.BPM_UNITS do\n                                adjustDefaultTempo(-1)\n                                _dialCC.bpmAccum = _dialCC.bpmAccum + _dialCC.BPM_UNITS\n                            end\n                        end\n                    end\n                    _dialCC.lastBPM = newVal\n                    actionTriggered = true\n                    break\n                end\n\n                -- Pitch dial: delta-tracking CC adjusts active pattern's octave transpose\n                if act.id == \"pitch_dial\" and mapping.type == \"cc\"\n                   and ev.type == \"cc\" and ev.note == mapping.number then\n                    local newVal = ev.velocity\n                    if _dialCC.lastPitch ~= nil then\n                        local delta = newVal - _dialCC.lastPitch\n                        -- Ignore wrap-around jumps (> half of 0-127 range)\n                        if math.abs(delta) <= 64 then\n                            _dialCC.pitchAccum = _dialCC.pitchAccum + delta\n                            local idx = state.activeSequenceIndex\n                            while _dialCC.pitchAccum >= _dialCC.OCT_UNITS do\n                                local cur = state.sequenceOctaveTranspose[idx] or 0\n                                state.sequenceOctaveTranspose[idx] = math.min(3, cur + 1)\n                                _dialCC.pitchAccum = _dialCC.pitchAccum - _dialCC.OCT_UNITS\n                            end\n                            while _dialCC.pitchAccum <= -_dialCC.OCT_UNITS do\n                                local cur = state.sequenceOctaveTranspose[idx] or 0\n                                state.sequenceOctaveTranspose[idx] = math.max(-3, cur - 1)\n                                _dialCC.pitchAccum = _dialCC.pitchAccum + _dialCC.OCT_UNITS\n                            end\n                        end\n                    end\n                    _dialCC.lastPitch = newVal\n                    actionTriggered = true\n                    break\n                end\n\n                -- Step buttons: range of 16 consecutive notes/CCs from base → jump to step\n                if act.id == \"step_buttons\" then\n                    local received = nil\n                    if mapping.type == \"note\" and ev.type == \"noteOn\" then\n                        received = ev.note\n                    elseif mapping.type == \"cc\" and ev.type == \"cc\" and ev.velocity > 0 then\n                        received = ev.note  -- CC number stored in .note field\n                    end\n                    if received then\n                        local offset = received - mapping.number\n                        if offset >= 0 and offset <= 15 then\n                            local stepNum = offset + 1\n                            local maxSteps = state.sequenceLength or 1\n                            if stepNum <= maxSteps then\n                                if state.isPlaying then\n                                    -- Jump playback to this step immediately\n                                    state.playbackPosition = stepNum\n                                    state.currentPlaybackStep = stepNum\n                                    playbackClock.nextStepAt =\n                                        getCurrentTimeMilliseconds() + state.stepDuration\n                                else\n                                    state.currentStep = stepNum\n                                end\n                            end\n                            actionTriggered = true\n                            break\n                        end\n                    end\n                end\n\n                -- Standard button/note mappings\n                local matches = false\n                if mapping.type == \"cc\" and ev.type == \"cc\"\n                   and ev.note == mapping.number and ev.velocity > 0 then\n                    matches = true\n                elseif mapping.type == \"note\" and ev.type == \"noteOn\"\n                   and ev.note == mapping.number then\n                    matches = true\n                end\n                if matches then\n                    triggerMidiAction(act.id)\n                    actionTriggered = true\n                    break\n                end\n            end\n        end\n\n        -- MIDI transport messages always control playback.\n        -- transport_start acts as a toggle so single-button controllers (e.g. Orba 2)\n        -- that send 0xFA for both play and pause work correctly.\n        if ev.type == \"transport_start\" then\n            if state.isPlaying then pausePlayback() else startPlayback() end\n            goto continue\n        elseif ev.type == \"transport_continue\" then\n            if not state.isPlaying then startPlayback() end\n            goto continue\n        elseif ev.type == \"transport_stop\" then\n            if state.isPlaying then pausePlayback() end\n            goto continue\n        elseif ev.type == \"sysex\" then\n            -- ev.note = sub-type byte (0x06 = MMC), ev.velocity = command byte\n            if ev.note == 0x06 then\n                local cmd = ev.velocity\n                print(string.format(\"[MIDI] sysex MMC cmd=0x%02X\", cmd))\n                if cmd == 0x02 or cmd == 0x03 then      -- MMC Play / Deferred Play\n                    if not state.isPlaying then startPlayback() end\n                elseif cmd == 0x01 then                  -- MMC Stop\n                    if state.isPlaying then stopPlayback() end\n                elseif cmd == 0x06 then                  -- MMC Record Strobe (loop on many Roland devices)\n                    state.loopPlayback = not state.loopPlayback\n                    _syncKeyLineInTextBuffer()\n                elseif cmd == 0x04 then                  -- MMC Fast Forward → tempo up\n                    adjustDefaultTempo(1)\n                elseif cmd == 0x05 then                  -- MMC Rewind → tempo down\n                    adjustDefaultTempo(-1)\n                end\n            else\n                print(string.format(\"[MIDI] sysex unknown: subtype=0x%02X cmd=0x%02X\", ev.note, ev.velocity))\n            end\n            goto continue\n        end\n\n        -- Log unmapped events to help identify unknown controller messages.\n        -- Silently ignore standard MIDI system CCs that carry no useful action:\n        --   121 = Reset All Controllers (sent by P-6 on connect)\n        --   120 = All Sound Off  123 = All Notes Off  124-127 = channel mode msgs\n        local SILENT_CC = { [120]=true,[121]=true,[123]=true,[124]=true,[125]=true,[126]=true,[127]=true }\n        if not actionTriggered and ev.type ~= \"noteOn\" and ev.type ~= \"noteOff\" then\n            if not (ev.type == \"cc\" and SILENT_CC[ev.note]) then\n                print(string.format(\"[MIDI] unmapped: type=%s ch=%d note=%d vel=%d\",\n                    tostring(ev.type), ev.channel or 0, ev.note or 0, ev.velocity or 0))\n            end\n        end\n\n        -- Play as solfege note if not consumed by a control mapping\n        if not actionTriggered and ev.type == \"noteOn\" then\n            -- MIDI note → solfege: same mapping as midi_import.lua\n            local noteIndex = ev.note % 12\n            local octave = math.floor(ev.note / 12) - 1\n            octave = math.max(2, math.min(7, octave))\n\n            state.selectedNote = noteIndex\n            state.currentOctave = octave\n\n            -- If the syllable dropdown is open, use the MIDI note to select and confirm\n            -- the closest matching option instead of writing directly to the step.\n            if state.syllableDropdownOpen then\n                local options = getSyllableDropdownOptions()\n                local bestIdx, bestDist = nil, math.huge\n                for i, opt in ipairs(options) do\n                    -- Convert option {note, octave} to MIDI note number for comparison\n                    local optMidi = (opt.octave + 1) * 12 + opt.note\n                    local dist = math.abs(optMidi - ev.note)\n                    if dist < bestDist then bestDist = dist; bestIdx = i end\n                end\n                if bestIdx then\n                    state.syllableDropdownSelection = bestIdx\n                    local selOpt = options[bestIdx]\n                    state.syllableDropdownSelectedNote = selOpt.note\n                    state.syllableDropdownSelectedOctave = selOpt.octave\n                    applySyllableDropdownChoice(nil)\n                end\n                goto continue\n            end\n\n            -- Snap to the nearest note in the current solfege scale.\n            -- This ensures a MIDI keyboard (e.g. Artiphon Orba 2) tuned to a different\n            -- root or sending chromatic notes still produces in-scale step entries.\n            if state.solfegeScale ~= \"all\" then\n                local options = getSyllableDropdownOptions()\n                local bestIdx, bestDist = nil, math.huge\n                for i, opt in ipairs(options) do\n                    local optMidi = (opt.octave + 1) * 12 + opt.note\n                    local dist = math.abs(optMidi - ev.note)\n                    if dist < bestDist then bestDist = dist; bestIdx = i end\n                end\n                if bestIdx then\n                    local selOpt = options[bestIdx]\n                    noteIndex = selOpt.note\n                    octave    = selOpt.octave\n                    state.selectedNote  = noteIndex\n                    state.currentOctave = octave\n                end\n            end\n\n            -- Live MIDI recording: write to currently-playing step when armed\n            local canLiveRecord = state.isPlaying\n                and state.midiLiveRecord\n                and not state.showingModeSelect\n                and not state.showingTemplateBrowser\n                and not state.showingSequenceSelect\n                and not state.showingStepSelect\n                and not state.singSolfegeMode\n\n            if canLiveRecord and ev.type == \"noteOn\" then\n                local seqIdx = state.currentPlaybackSequenceIndex\n                local stepIdx = state.currentPlaybackStep\n                local targetSeq = state.sequences[seqIdx]\n                if targetSeq and stepIdx and stepIdx >= 1 then\n                    recordStepHistoryIfNeeded()\n                    local existingStep = targetSeq[stepIdx]\n                    targetSeq[stepIdx] = {\n                        note   = noteIndex,\n                        octave = octave,\n                        lyric  = existingStep and existingStep.lyric or nil,\n                        length = existingStep and existingStep.length or nil,\n                        gate   = existingStep and existingStep.gate or nil,\n                    }\n                    markSequenceDirty()\n                end\n                goto continue\n            end\n\n            local canWriteMidiStep = not state.isPlaying\n               and not state.showingModeSelect\n               and not state.showingTemplateBrowser\n               and not state.showingSequenceSelect\n               and not state.showingStepSelect\n               and not state.showingMidiInPicker\n               and not state.showingMicInputPicker\n               and not state.showingGamepadPicker\n               and not state.showingMidiControls\n               and not state.singSolfegeMode\n               and state.editMode ~= \"chord\"\n\n            if canWriteMidiStep then\n                local holdSteps = math.max(2, math.ceil(2000 / math.max(1, (state.stepDuration or 500))))\n                if midiHoldInput.stepIndex and #midiHoldInput.heldNotes > 0 then\n                    -- Another key pressed while step still held: add to chord\n                    local chordStep = state.sequence[midiHoldInput.stepIndex]\n                    if chordStep then\n                        -- Convert single note to chord if needed\n                        if chordStep.note ~= nil and not chordStep.notes then\n                            chordStep.notes = {{note = chordStep.note, octave = chordStep.octave}}\n                            chordStep.note = nil\n                            chordStep.octave = nil\n                        end\n                        if not chordStep.notes then chordStep.notes = {} end\n                        if #chordStep.notes < 3 then\n                            table.insert(chordStep.notes, {note = noteIndex, octave = octave})\n                            markSequenceDirty()\n                        end\n                    end\n                    table.insert(midiHoldInput.heldNotes, ev.note)\n                    local stepWillBeMuted = state.muteStepPreview or state.muteMode\n                        or (chordStep and chordStep.muted)\n                    if not stepWillBeMuted then\n                        local transposedNote, transposedOctave = transposeNoteForKey(noteIndex, octave)\n                        playNote(transposedNote, transposedOctave, midiHoldInput.stepIndex, primaryVoice, nil, holdSteps, 1.0)\n                    end\n                else\n                    -- No step in progress: commit any previous hold, start a new step\n                    if midiHoldInput.startTime and midiHoldInput.stepIndex then\n                        stopVoice(primaryVoice)\n                        moveCursorAfterHeldStep(midiHoldInput.stepIndex, midiHoldInput.length)\n                        midiHoldInput.heldNotes = {}\n                        midiHoldInput.stepIndex = nil\n                        midiHoldInput.startTime = nil\n                        midiHoldInput.length = nil\n                    end\n                    local targetStep = state.currentStep\n                    if targetStep == state.sequenceLength + 1 then\n                        state.sequenceLength = state.sequenceLength + 1\n                        state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n                    end\n                    local existingStep = state.sequence[targetStep]\n                    state.sequence[targetStep] = {\n                        note = noteIndex,\n                        octave = octave,\n                        lyric = existingStep and existingStep.lyric or nil,\n                        length = existingStep and existingStep.length or nil,\n                        gate = existingStep and existingStep.gate or nil,\n                        muted = state.muteMode or (existingStep and existingStep.muted) or nil,\n                    }\n                    markSequenceDirty()\n                    local stepWillBeMuted = state.muteStepPreview or state.muteMode or (existingStep and existingStep.muted)\n                    if not stepWillBeMuted then\n                        local transposedNote, transposedOctave = transposeNoteForKey(noteIndex, octave)\n                        stopVoice(primaryVoice)\n                        playNote(transposedNote, transposedOctave, targetStep, primaryVoice, nil, holdSteps, 1.0)\n                    end\n                    midiHoldInput.heldNotes = {ev.note}\n                    midiHoldInput.stepIndex = targetStep\n                    midiHoldInput.startTime = getCurrentTimeMilliseconds()\n                    midiHoldInput.length = 1\n                    -- Don't advance cursor yet — wait until all notes released\n                end\n            else\n                -- In mute mode, MIDI step-entry should not audition notes in-app.\n                if not state.muteStepPreview and not state.muteMode then\n                    local transposedNote, transposedOctave = transposeNoteForKey(noteIndex, octave)\n                    playNote(transposedNote, transposedOctave, nil, primaryVoice)\n                end\n            end\n        elseif not actionTriggered and ev.type == \"noteOff\" then\n            if midiHoldInput.stepIndex and #midiHoldInput.heldNotes > 0 then\n                -- Remove released note from held set\n                for i = #midiHoldInput.heldNotes, 1, -1 do\n                    if midiHoldInput.heldNotes[i] == ev.note then\n                        table.remove(midiHoldInput.heldNotes, i)\n                        break\n                    end\n                end\n                -- When all notes released, advance cursor past the step\n                if #midiHoldInput.heldNotes == 0 then\n                    stopVoice(primaryVoice)\n                    moveCursorAfterHeldStep(midiHoldInput.stepIndex, midiHoldInput.length)\n                    midiHoldInput.stepIndex = nil\n                    midiHoldInput.startTime = nil\n                    midiHoldInput.length = nil\n                end\n            end\n        end\n\n        ::continue::\n    end\nend\n\nfunction playSingleNote(noteIndex, octave, stepIndex, voice, synth1, synth2, stepLength, gate)\n    if state.audioMuted then\n        return\n    end\n    if noteIndex == nil or noteIndex == 13 or core.noteFreqs[noteIndex] == nil then\n        return\n    end\n\n    synth1:stop()\n    synth2:stop()\n\n    local baseFreq = core.noteFreqs[noteIndex]\n    local octaveMultiplier = 2 ^ (octave - 4)\n    local freq = baseFreq * octaveMultiplier\n\n    local velocity = 1.0\n    if state.isPlaying then\n        local velocityStep = stepIndex or state.playbackPosition\n        if velocityStep % 4 == 1 then\n            velocity = 1.0\n        elseif velocityStep % 4 == 3 then\n            velocity = 0.85\n        else\n            velocity = 0.9\n        end\n    end\n\n    local stepDurationMs = state.stepDuration or 500\n    local noteDurationSeconds = math.max(0.05, (stepDurationMs / 1000) * (gate or 0.9) * (stepLength or 1))\n\n    synth1:playNote(freq, velocity, noteDurationSeconds)\n    synth2:playNote(freq, velocity, noteDurationSeconds)\n    sendMidiNoteOn(noteIndex, octave, stepIndex, noteDurationSeconds)\nend\n\nfunction playNote(noteIndex, octave, stepIndex, voice, allowAcapella, stepLength, gate)\n    voice = voice or primaryVoice\n    if state.audioMuted then\n        return\n    end\n    if state.muteMode and not state.isPlaying then\n        return\n    end\n    if state.acapellaMode and not allowAcapella then\n        return\n    end\n    -- Check if it's a rest (note index 13 or nil frequency)\n    if noteIndex == nil or noteIndex == 13 or core.noteFreqs[noteIndex] == nil then\n        print(\"playNote: Skipping rest (note=\" .. tostring(noteIndex) .. \")\")\n        return -- Don't play anything for rests\n    end\n\n    -- Use sample mode if we have a recorded sample synth\n    if useSampleMode and voice.sampleSynth then\n        -- Stop any currently playing note\n        voice.sampleSynth:noteOff()\n\n        local baseFreq = core.noteFreqs[noteIndex]\n        local octaveMultiplier = 2 ^ (octave - 4)\n        local targetFreq = baseFreq * octaveMultiplier\n\n        -- Add velocity variation\n        local velocity = 1.0\n        if state.isPlaying then\n            local velocityStep = stepIndex or state.playbackPosition\n            if velocityStep % 4 == 1 then\n                velocity = 1.0\n            elseif velocityStep % 4 == 3 then\n                velocity = 0.85\n            else\n                velocity = 0.9\n            end\n        end\n\n        -- Play the note using the synth (with envelope and looping!)\n        -- The synth will loop the subsample smoothly while the note is held\n        local stepDurationMs = state.stepDuration or 500\n        local noteDurationSeconds = math.max(0.05, (stepDurationMs / 1000) * (gate or 0.9) * (stepLength or 1))\n        voice.sampleSynth:playNote(targetFreq, velocity, noteDurationSeconds)\n        sendMidiNoteOn(noteIndex, octave, stepIndex, noteDurationSeconds)\n    else\n        -- Use synth mode (original)\n        playSingleNote(noteIndex, octave, stepIndex, voice, voice.synth, voice.synth2, stepLength, gate)\n    end\nend\n\nfunction playChord(chordNotes, stepIndex, voice, allowAcapella, stepLength, gate)\n    voice = voice or primaryVoice\n    if state.audioMuted then\n        return\n    end\n    if state.muteMode and not state.isPlaying then\n        return\n    end\n    if state.acapellaMode and not allowAcapella then\n        return\n    end\n\n    -- Stop all synths\n    if voice.synth then voice.synth:stop() end\n    if voice.synth2 then voice.synth2:stop() end\n    if voice.chordSynth1 then voice.chordSynth1:stop() end\n    if voice.chordSynth1b then voice.chordSynth1b:stop() end\n    if voice.chordSynth2 then voice.chordSynth2:stop() end\n    if voice.chordSynth2b then voice.chordSynth2b:stop() end\n\n    -- Play each note in the chord\n    for i, noteData in ipairs(chordNotes) do\n        if noteData and noteData.note ~= nil and noteData.note ~= 13 and core.noteFreqs[noteData.note] ~= nil then\n            if i == 1 then\n                playSingleNote(noteData.note, noteData.octave, stepIndex, voice, voice.synth, voice.synth2, stepLength, gate)\n            elseif i == 2 then\n                playSingleNote(noteData.note, noteData.octave, stepIndex, voice, voice.chordSynth1, voice.chordSynth1b, stepLength, gate)\n            elseif i == 3 then\n                playSingleNote(noteData.note, noteData.octave, stepIndex, voice, voice.chordSynth2, voice.chordSynth2b, stepLength, gate)\n            end\n        end\n    end\nend\n\nresetSingSolfegeOctaveOffset = function()\n    state.singSolfegeOctaveOffset = 0\nend\n\n-- Ear Training (on core to conserve locals — main.lua is at the 200 limit)\ncore._et = {noteIndex = 0, timer = nil}\n\ncore._et.stopPlayback = function()\n    core._et.timer = nil\n    core._et.noteIndex = 0\nend\n\ncore._et.playQuestion = function()\n    local q = state.earTrainingQuestion\n    if not q or not q.notes then return end\n    core._et.stopPlayback()\n    if q.playAsChord then\n        local chordNotes = {}\n        for _, nd in ipairs(q.notes) do\n            chordNotes[#chordNotes + 1] = {note = nd.note, octave = nd.octave}\n        end\n        playChord(chordNotes, nil, primaryVoice, true, 4, 0.9)\n    else\n        core._et.noteIndex = 1\n        local nd = q.notes[1]\n        playNote(nd.note, nd.octave, nil, primaryVoice, true, 1, 0.9)\n        local tempo = state.tempo or 80\n        local intervalMs = (60 / tempo) * 1000\n        state._earTrainingNextNoteAt = timerAdapter.getCurrentTimeMs() + intervalMs\n    end\nend\n\ncore._et.tick = function()\n    local q = state.earTrainingQuestion\n    if not q or not q.notes or not state._earTrainingNextNoteAt then return end\n    local now = timerAdapter.getCurrentTimeMs()\n    if now < state._earTrainingNextNoteAt then return end\n    core._et.noteIndex = core._et.noteIndex + 1\n    if core._et.noteIndex > #q.notes then\n        state._earTrainingNextNoteAt = nil\n        core._et.noteIndex = 0\n        return\n    end\n    local nd = q.notes[core._et.noteIndex]\n    playNote(nd.note, nd.octave, nil, primaryVoice, true, 1, 0.9)\n    if core._et.noteIndex < #q.notes then\n        local tempo = state.tempo or 80\n        state._earTrainingNextNoteAt = now + (60 / tempo) * 1000\n    else\n        state._earTrainingNextNoteAt = nil\n        core._et.noteIndex = 0\n    end\nend\n\ncore._et.start = function(exerciseType)\n    state.earTrainingMode = true\n    state.earTrainingExercise = exerciseType\n    state.earTrainingScore = {correct = 0, total = 0}\n    state.earTrainingRevealed = false\n    state.earTrainingSelectedOption = 0\n    state.earTrainingDictationInput = \"\"\n    state.earTrainingDictationCursor = 0\n    state.earTrainingQuestion = core.earTraining.generateQuestion(exerciseType, state.earTrainingDifficulty)\n    core._et.playQuestion()\nend\n\ncore._et.next = function()\n    state.earTrainingRevealed = false\n    state.earTrainingSelectedOption = 0\n    state.earTrainingDictationInput = \"\"\n    state.earTrainingDictationCursor = 0\n    state.earTrainingQuestion = core.earTraining.generateQuestion(\n        state.earTrainingExercise, state.earTrainingDifficulty)\n    core._et.playQuestion()\nend\n\ncore._et.submit = function(answer)\n    local q = state.earTrainingQuestion\n    if not q or state.earTrainingRevealed then return end\n    state.earTrainingRevealed = true\n    state.earTrainingScore.total = state.earTrainingScore.total + 1\n    if q.type == \"dictation\" then\n        local allCorrect, count = core.earTraining.checkDictationAnswer(q, answer)\n        state._earTrainingDictationCorrect = count\n        if allCorrect then\n            state.earTrainingScore.correct = state.earTrainingScore.correct + 1\n        end\n    else\n        if answer == q.answer then\n            state.earTrainingScore.correct = state.earTrainingScore.correct + 1\n        end\n    end\nend\n\ncore._et.exit = function()\n    state.earTrainingMode = false\n    state.earTrainingExercise = nil\n    state.earTrainingQuestion = nil\n    state.earTrainingRevealed = false\n    core._et.stopPlayback()\n    state._earTrainingNextNoteAt = nil\nend\n\nfunction getSequencerRootOctave()\n    local baseOctave = state.currentOctave or state.keyOctave or 4\n    local playbackSequence = state.isPlaying and getPlaybackSequence() or state.sequence\n    local stepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n    local stepData = playbackSequence and playbackSequence[stepIndex]\n    if stepData then\n        if core.isChord(stepData) and stepData.notes and stepData.notes[1] then\n            baseOctave = stepData.notes[1].octave or baseOctave\n        elseif stepData.octave ~= nil then\n            baseOctave = stepData.octave\n        end\n    end\n    local sequenceIndex = state.activeSequenceIndex or 1\n    local octaveTranspose = 0\n    if state.sequenceOctaveTranspose then\n        octaveTranspose = state.sequenceOctaveTranspose[sequenceIndex] or 0\n    end\n    return math.max(2, math.min(7, baseOctave + octaveTranspose))\nend\n\nplaySingSolfegeRoot = function()\n    if not state.singSolfegeMode then\n        return\n    end\n    -- Mute the mic BEFORE playing to prevent any audio pickup\n    local now = getCurrentTimeMilliseconds()\n    pitchFeedbackDeafUntil = now + pitchFeedbackDeafDurationMs\n    if useCPitchDetector and pitchDetector.mute then\n        pitchDetector.mute()\n        pitchDetectorMuted = true\n    end\n    -- Clear any pitch detection state\n    clearPitchHistory()\n    state.pitchMatchStatus = \"silent\"\n    state.pitchDetectedFrequency = nil\n    state.pitchDetectedNote = nil\n    state.pitchDetectedOctave = nil\n    state.pitchHoldMs = 0\n    state.pitchHoldAchieved = false\n\n    local baseOctave = getSequencerRootOctave()\n    local octave = applySingSolfegeOctaveOffset(baseOctave)\n    local rootNote, rootOctave = transposeNoteForKey(0, octave)\n    playNote(rootNote, rootOctave, nil, primaryVoice, true)\nend\n\nfunction getSingSolfegeRestAdvanceDelay()\n    local baseDuration = state.stepDuration or 400\n    return math.min(1200, math.max(200, math.floor(baseDuration * 0.6)))\nend\n\nfunction getSingSolfegeStepData()\n    local stepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n    local playbackSequence = state.isPlaying and getPlaybackSequence() or state.sequence\n    return playbackSequence[stepIndex]\nend\n\nfunction setPlaybackRootNote()\n    if not state.randomizeRootPlayback then\n        state.playbackRootNote = state.rootNote or 0\n        return\n    end\n    seedRandom()\n    local nextRoot = math.random(0, 11)\n    if nextRoot == (state.playbackRootNote or -1) then\n        nextRoot = (nextRoot + 1) % 12\n    end\n    state.playbackRootNote = nextRoot\nend\n\nfunction playPreferencePreview(noteIndex, octave)\n    if not state.soundPreviewEnabled then\n        return\n    end\n    playNote(noteIndex, octave, nil, primaryVoice)\nend\n\nfunction playKeyCenterPreview()\n    if state.acapellaMode then\n        return\n    end\n    local previewNote, previewOctave = transposeNoteForKey(0, state.keyOctave)\n    playNote(previewNote, previewOctave, nil, primaryVoice)\nend\n\nfunction playCurrentStepPreview()\n    if not state.soundPreviewOnNavigation or state.muteStepPreview then\n        return\n    end\n    local stepData = state.sequence[state.currentStep]\n    if stepData and not stepData.muted then\n        local previewStepLength = math.min(core.getStepLength(stepData), 1)\n        local previewGate = stepData.gate or 0.9\n        -- Handle both single notes and chords\n        if core.isChord(stepData) then\n            -- Play the chord\n            local transposedNotes = {}\n            for _, noteData in ipairs(stepData.notes) do\n                local transposedNote, transposedOctave = transposeNoteForKey(noteData.note, noteData.octave)\n                table.insert(transposedNotes, {note = transposedNote, octave = transposedOctave})\n            end\n            playChord(transposedNotes, state.currentStep, primaryVoice, nil, previewStepLength, previewGate)\n        else\n            -- Play single note\n            local transposedNote, transposedOctave = transposeNoteForKey(stepData.note, stepData.octave)\n            playNote(transposedNote, transposedOctave, state.currentStep, primaryVoice, nil, previewStepLength, previewGate)\n        end\n    end\nend\n\nfunction playSelectedNotePreview()\n    if not state.soundPreviewOnNavigation or state.muteStepPreview then\n        return\n    end\n    if state.selectedNote == nil or state.selectedNote == 13 then\n        return\n    end\n    local stepData = state.sequence[state.currentStep]\n    local previewStepLength = stepData and core.getStepLength(stepData) or 1\n    local previewGate = stepData and (stepData.gate or 0.9) or 0.9\n    local transposedNote, transposedOctave = transposeNoteForKey(state.selectedNote, state.currentOctave)\n    playNote(transposedNote, transposedOctave, state.currentStep, primaryVoice, nil, previewStepLength, previewGate)\nend\n\nfunction _tryPlaySolfegePreview()\n    if not state.soundPreviewOnNavigation or state.muteStepPreview then return end\n    if (state.solfegeTextMode or \"both\") == \"lyrics\" then return end\n    local buf = state.solfegeInputBuffer or \"\"\n    local cur = state.solfegeInputCursor or (#buf + 1)\n    local word = _solfegeCurrentWord(buf, cur)\n    if word == \"\" then\n        state._previewLastNote = nil\n        state._previewLastOctave = nil\n        return\n    end\n    local parsed = parseSolfegeToken(word)\n    if not parsed or not parsed.note or parsed.note == 13 then return end\n    local oct = parsed.octave or state.currentOctave or 4\n    local n, o = transposeNoteForKey(parsed.note, oct)\n    if n == state._previewLastNote and o == state._previewLastOctave then return end\n    state._previewLastNote = n\n    state._previewLastOctave = o\n    playNote(n, o, nil, primaryVoice, nil, 1, 0.9)\nend\n\nfunction adjustPlaybackStop(direction)\n    local optionIndex = core.getPlaybackStopOptionIndex(state.playbackStopSeconds)\n    optionIndex = optionIndex + direction\n    if optionIndex < 1 then\n        optionIndex = #core.playbackStopOptions\n    elseif optionIndex > #core.playbackStopOptions then\n        optionIndex = 1\n    end\n    state.playbackStopSeconds = core.playbackStopOptions[optionIndex]\n    savePreferences()\n    if state.isPlaying then\n        schedulePlaybackStopTimer()\n    end\nend\n\nfunction adjustKeyLeadIn(direction)\n    local optionIndex = core.getKeyLeadInOptionIndex(state.keyLeadInBeats)\n    optionIndex = optionIndex + direction\n    if optionIndex < 1 then\n        optionIndex = #core.keyLeadInOptions\n    elseif optionIndex > #core.keyLeadInOptions then\n        optionIndex = 1\n    end\n    state.keyLeadInBeats = core.keyLeadInOptions[optionIndex]\n    savePreferences()\nend\n\nfunction adjustSolfegeScale(direction)\n    local modes = core.solfegeScaleModes or {\"major\", \"natural_minor\", \"harmonic_minor\", \"melodic_minor\", \"all\"}\n    local current = state.solfegeScale or \"major\"\n    local currentIndex = 1\n    for i, mode in ipairs(modes) do\n        if mode == current then\n            currentIndex = i\n            break\n        end\n    end\n\n    local nextIndex = currentIndex + (direction or 1)\n    if nextIndex < 1 then\n        nextIndex = #modes\n    elseif nextIndex > #modes then\n        nextIndex = 1\n    end\n\n    state.solfegeScale = modes[nextIndex]\n    refreshSolfegeNotes()\n    snapKeyNoteToScale()\n    savePreferences()\nend\n\n-- Scale/transpose helpers grouped into one table. Declared global so functions\n-- defined earlier in the file (e.g. _stepsSyllableCycleAtCursor) can access it.\n_scaleOps = {}\n_scaleOps.notesByMode = {\n    major         = {0, 2, 4, 5, 7, 9, 11, 12},\n    natural_minor = {0, 2, 3, 5, 7, 8, 10, 12},\n    harmonic_minor= {0, 2, 3, 5, 7, 8, 11, 12},\n    melodic_minor = {0, 2, 3, 5, 7, 9, 11, 12},\n    all           = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},\n    custom        = {0, 2, 4, 5, 7, 9, 11, 12},\n}\n\n-- Snap state.keyNote to the nearest note in the current scale (excludes octave duplicate 12).\nfunction snapKeyNoteToScale()\n    local mode = state.solfegeScale or \"major\"\n    local notes = _scaleOps.notesByMode[mode] or _scaleOps.notesByMode.major\n    local kn = state.keyNote or 0\n    local best, bestDist = 0, 999\n    for _, n in ipairs(notes) do\n        if n < 12 then\n            local d = math.abs(n - kn)\n            if d < bestDist then bestDist = d; best = n end\n        end\n    end\n    state.keyNote = best\nend\n\nfunction _scaleOps.applyCustom(str)\n    -- Parse \"2 2 1 2 2 2 1\" into {0,2,4,5,7,9,11,12}; return nil + errmsg on failure.\n    local function parseScaleIntervals(s)\n        local nums = {}\n        for n in s:gmatch(\"%d+\") do\n            nums[#nums + 1] = tonumber(n)\n        end\n        if #nums < 2 then return nil, \"need at least 2 intervals\" end\n        local sum = 0\n        for _, v in ipairs(nums) do\n            if v < 1 then return nil, \"each interval must be >= 1\" end\n            sum = sum + v\n        end\n        if sum ~= 12 then return nil, \"intervals must sum to 12 (got \" .. sum .. \")\" end\n        local notes = {0}\n        local pos = 0\n        for _, v in ipairs(nums) do\n            pos = pos + v\n            notes[#notes + 1] = pos\n        end\n        return notes, nil\n    end\n    local notes, err = parseScaleIntervals(str)\n    if not notes then return false, err end\n    _scaleOps.notesByMode.custom = notes\n    if ui.setCustomScaleStepOptions then\n        ui.setCustomScaleStepOptions(notes)\n    end\n    state.customScaleIntervals = str\n    return true, nil\nend\n\nfunction openScaleStepsInput()\n    -- Pre-fill with current custom intervals if set, else major\n    local buf = state.customScaleIntervals or \"2 2 1 2 2 2 1\"\n    state.scaleStepsInputBuffer = buf\n    state.scaleStepsInputOpen = true\n    state.scaleStepsInputError = nil\nend\n\nfunction _scaleOps.stepSelected(direction)\n    local mode = state.solfegeScale or \"major\"\n    local scaleNotes = _scaleOps.notesByMode[mode] or _scaleOps.notesByMode.major\n    local dir = (direction or 1) >= 0 and 1 or -1\n    local current = state.selectedNote or 0\n\n    if dir > 0 then\n        for _, note in ipairs(scaleNotes) do\n            if note > current then\n                state.selectedNote = note\n                return\n            end\n        end\n        state.selectedNote = scaleNotes[1]\n        state.currentOctave = math.min(state.currentOctave + 1, 7)\n        return\n    end\n\n    for i = #scaleNotes, 1, -1 do\n        local note = scaleNotes[i]\n        if note < current then\n            state.selectedNote = note\n            return\n        end\n    end\n    state.selectedNote = scaleNotes[#scaleNotes]\n    state.currentOctave = math.max(state.currentOctave - 1, 2)\nend\n\nfunction _scaleOps.stepNote(noteValue, octaveValue, direction)\n    if noteValue == nil or noteValue == 13 then\n        return nil, nil\n    end\n\n    local mode = state.solfegeScale or \"major\"\n    local scaleNotes = _scaleOps.notesByMode[mode] or _scaleOps.notesByMode.major\n    local dir = (direction or 1) >= 0 and 1 or -1\n    local currentNote = tonumber(noteValue) or 0\n    local currentOctave = tonumber(octaveValue) or 4\n\n    if dir > 0 then\n        for _, scaleNote in ipairs(scaleNotes) do\n            if scaleNote > currentNote then\n                return scaleNote, currentOctave\n            end\n        end\n        return scaleNotes[1], math.min(currentOctave + 1, 7)\n    end\n\n    for i = #scaleNotes, 1, -1 do\n        local scaleNote = scaleNotes[i]\n        if scaleNote < currentNote then\n            return scaleNote, currentOctave\n        end\n    end\n\n    return scaleNotes[#scaleNotes], math.max(currentOctave - 1, 2)\nend\n\nfunction adjustRootNote(direction)\n    local mode = state.solfegeScale or \"major\"\n    local scaleNotes = _scaleOps.notesByMode[mode] or _scaleOps.notesByMode.major\n    local currentRoot = ((state.rootNote or 0) % 12 + 12) % 12\n    local dir = (direction or 1) >= 0 and 1 or -1\n\n    local options = {}\n    local seen = {}\n    for _, note in ipairs(scaleNotes) do\n        local normalized = note % 12\n        if not seen[normalized] then\n            seen[normalized] = true\n            table.insert(options, normalized)\n        end\n    end\n\n    local targetRoot = currentRoot\n    if #options > 0 and #options < 12 then\n        local optionIndex = nil\n        for i, note in ipairs(options) do\n            if note == currentRoot then\n                optionIndex = i\n                break\n            end\n        end\n\n        if optionIndex then\n            optionIndex = optionIndex + dir\n            if optionIndex < 1 then\n                optionIndex = #options\n            elseif optionIndex > #options then\n                optionIndex = 1\n            end\n            targetRoot = options[optionIndex]\n        else\n            local candidate = nil\n            if dir > 0 then\n                for _, note in ipairs(options) do\n                    if note > currentRoot then\n                        candidate = note\n                        break\n                    end\n                end\n                targetRoot = candidate or options[1]\n            else\n                for i = #options, 1, -1 do\n                    if options[i] < currentRoot then\n                        candidate = options[i]\n                        break\n                    end\n                end\n                targetRoot = candidate or options[#options]\n            end\n        end\n    else\n        targetRoot = (currentRoot + dir + 12) % 12\n    end\n\n    _scaleOps.setRootFromSyllable(targetRoot)\nend\n\nfunction _scaleOps.transposeBySemitones(noteValue, octaveValue, semitoneDelta)\n    if noteValue == nil or noteValue == 13 then\n        return noteValue, octaveValue\n    end\n\n    local octave = tonumber(octaveValue) or 4\n    local absolute = (octave * 12) + noteValue + semitoneDelta\n    local minAbsolute = (2 * 12)\n    local maxAbsolute = (7 * 12) + 11\n    absolute = math.max(minAbsolute, math.min(maxAbsolute, absolute))\n\n    local newOctave = math.floor(absolute / 12)\n    local newNote = absolute % 12\n    return newNote, newOctave\nend\n\nfunction _scaleOps.transposeAll(semitoneDelta)\n    if semitoneDelta == 0 then\n        return\n    end\n\n    for seqIndex = 1, core.maxSequences do\n        local seq = state.sequences[seqIndex]\n        local len = state.sequenceLengths[seqIndex] or 0\n        for stepIndex = 1, len do\n            local stepData = seq and seq[stepIndex]\n            if stepData then\n                if core.isChord(stepData) then\n                    for _, noteData in ipairs(stepData.notes or {}) do\n                        if noteData and noteData.note ~= nil then\n                            noteData.note, noteData.octave = _scaleOps.transposeBySemitones(noteData.note, noteData.octave, semitoneDelta)\n                        end\n                    end\n                elseif stepData.note ~= nil then\n                    stepData.note, stepData.octave = _scaleOps.transposeBySemitones(stepData.note, stepData.octave, semitoneDelta)\n                end\n            end\n        end\n    end\nend\n\n-- Remap all step notes from oldMode's scale degrees to the equivalent\n-- degree in newMode.  e.g. Mi(4) in major → Me(3) in natural_minor.\n-- Notes that aren't diatonic in the old scale are left unchanged.\nfunction _scaleOps.remapNotesForScaleChange(oldMode, newMode)\n    if oldMode == newMode then return end\n    -- \"all\" mode just shows chromatic names; no degree-based remap makes sense.\n    if oldMode == \"all\" or newMode == \"all\" then return end\n    local oldNotes = _scaleOps.notesByMode[oldMode] or _scaleOps.notesByMode.major\n    local newNotes = _scaleOps.notesByMode[newMode] or _scaleOps.notesByMode.major\n    -- Build old-note → new-note lookup by scale-degree position\n    local noteMap = {}\n    for i, oldNote in ipairs(oldNotes) do\n        local newNote = newNotes[i]\n        if newNote ~= nil then\n            noteMap[oldNote] = newNote\n        end\n    end\n    for seqIndex = 1, core.maxSequences do\n        local seq = state.sequences[seqIndex]\n        local len = state.sequenceLengths[seqIndex] or 0\n        for stepIndex = 1, len do\n            local stepData = seq and seq[stepIndex]\n            if stepData then\n                if core.isChord(stepData) then\n                    for _, noteData in ipairs(stepData.notes or {}) do\n                        if noteData and noteData.note ~= nil then\n                            noteData.note = noteMap[noteData.note] or noteData.note\n                        end\n                    end\n                elseif stepData.note ~= nil then\n                    stepData.note = noteMap[stepData.note] or stepData.note\n                end\n            end\n        end\n    end\n    -- Also remap the currently selected note\n    if state.selectedNote ~= nil and state.selectedNote ~= 13 then\n        state.selectedNote = noteMap[state.selectedNote] or state.selectedNote\n    end\nend\n\nfunction shiftAllStepOctaves(delta)\n    recordStepHistoryIfNeeded()\n    local seq = state.sequence\n    local len = state.sequenceLength or 0\n    for i = 1, len do\n        local s = seq[i]\n        if s then\n            s.octave = math.max(1, math.min(8, (s.octave or 4) + delta))\n        end\n    end\n    markSequenceDirty()\nend\n\nfunction _syncKeyLineInTextBuffer()\n    if (state.solfegeTextMode or \"both\") ~= \"steps\" then return end\n    local rootIdx = (state.rootNote or 0) % 12\n    local keyName = SOLFEGE_ROOT_TO_NAME[rootIdx + 1] or \"C\"\n    local oct = state.currentOctave or 4\n    local bpm = math.floor(state.tempo or 120)\n    local meter = tostring(state.meterNumerator or 4) .. \"/\" .. tostring(state.meterDenominator or 4)\n    local loopState = state.loopPlayback and \"On\" or \"Off\"\n    local lengthState = (state.showNoteLengths ~= false) and \"On\" or \"Off\"\n    local octState = (state.showOctaveNumbers ~= false) and \"On\" or \"Off\"\n    local defaultLabel = core.getStepBeatsShortLabel(state.stepBeats or 1)\n    local headerLine = \"Key:\" .. keyName .. \" Octave:\" .. oct .. \" BPM:\" .. bpm .. \" Meter:\" .. meter .. \" Loop:\" .. loopState .. \" Length:\" .. lengthState .. \" Oct:\" .. octState .. \" Default:\" .. defaultLabel\n    if state.solfegeInputActive then\n        -- Patch the header line BEFORE flushing live-apply so the updated\n        -- values (e.g. BPM) are already in the buffer when it is re-parsed.\n        local buf = state.solfegeInputBuffer or \"\"\n        local _hdrPatFull = \"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+ [Ll]oop:%a+ [Ll]ength:%a+ [Oo]ct:%a+ [Dd]efault:[%d/%.t]+\"\n        local _hdrPat = \"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+ [Ll]oop:%a+ [Ll]ength:%a+ [Oo]ct:%a+\"\n        if buf:match(_hdrPatFull) then\n            buf = buf:gsub(_hdrPatFull, headerLine)\n        elseif buf:match(_hdrPat) then\n            buf = buf:gsub(_hdrPat, headerLine)\n        elseif buf:match(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+ [Ll]oop:%a+ [Ll]ength:%a+\") then\n            buf = buf:gsub(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+ [Ll]oop:%a+ [Ll]ength:%a+\", headerLine)\n        elseif buf:match(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+ [Ll]oop:%a+\") then\n            buf = buf:gsub(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+ [Ll]oop:%a+\", headerLine)\n        elseif buf:match(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+\") then\n            buf = buf:gsub(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+ [Mm]eter:%d+/%d+\", headerLine)\n        elseif buf:match(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+\") then\n            buf = buf:gsub(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d+ [Bb][Pp][Mm]:%d+\", headerLine)\n        elseif buf:match(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d\") then\n            buf = buf:gsub(\"^[Kk]ey:[A-Za-z#]+ [Oo]ctave?:%d\", headerLine)\n        elseif buf:match(\"^[Kk]ey:[A-Za-z#]+\") then\n            buf = buf:gsub(\"^[Kk]ey:[A-Za-z#]+\", \"Key:\" .. keyName)\n        else\n            buf = headerLine .. \"\\n\" .. buf\n        end\n        state.solfegeInputBuffer = buf\n        _flushLiveApply()\n    else\n        -- Not editing: full re-serialize so the whole buffer stays current\n        state._solfegeSeqText = serializeSequenceToText()\n        state.solfegeInputBuffer = state._solfegeSeqText\n    end\n    _updateSolfegeStepHighlight()\nend\n\nfunction _scaleOps.setRootFromSyllable(targetRootNote)\n    state.keyShift = 0\n    local clampedTarget = math.max(0, math.min(12, math.floor(tonumber(targetRootNote) or 0)))\n    local previousRoot = state.rootNote or 0\n    if clampedTarget == previousRoot then\n        playKeyCenterPreview()\n        return\n    end\n\n    -- Concert-pitch-preserving key change: shift all step notes by\n    -- -semitoneShift so absolute pitch (note + rootNote) is unchanged,\n    -- while solfege labels update to show the note's relationship to\n    -- the new tonic.\n    local semitoneShift = clampedTarget - previousRoot\n    recordStepHistoryIfNeeded()\n    _scaleOps.transposeAll(-semitoneShift)\n\n    state.rootNote = clampedTarget\n    state.pendingRootNote = clampedTarget\n    if state.selectedNote ~= nil and state.selectedNote ~= 13 then\n        state.selectedNote = ((state.selectedNote - semitoneShift) % 12 + 12) % 12\n    end\n\n    markSequenceDirty()\n    savePreferences()\n    playKeyCenterPreview()\n    refreshDrone()\n    _syncKeyLineInTextBuffer()\nend\n\n-- Solfege-preserving key change: keeps all step note values (solfege syllables)\n-- unchanged, but shifts the tonic so every step sounds at a different pitch.\n-- e.g. Do Re Mi in C → Do Re Mi in D (sounds a step higher, labels unchanged).\nfunction _scaleOps.setRootOnly(targetRootNote)\n    local clampedTarget = math.max(0, math.min(12, math.floor(tonumber(targetRootNote) or 0)))\n    state.keyShift = 0\n    state.rootNote = clampedTarget\n    state.pendingRootNote = clampedTarget\n    savePreferences()\n    playKeyCenterPreview()\n    refreshDrone()\n    _syncKeyLineInTextBuffer()\nend\n\n-- Bake current rootNote offset into step data, then reset rootNote to 0 (Do=C).\n-- Steps are transposed so they sound identical, but the key shift is encoded\n-- in the note values rather than rootNote. The selected syllable becomes the\n-- new notated starting point relative to Do=C.\nfunction _scaleOps.bakeKeyIntoSteps()\n    local rootNote = state.rootNote or 0\n    state.keyShift = 0\n    if rootNote == 0 then\n        playKeyCenterPreview()\n        return\n    end\n    recordStepHistoryIfNeeded()\n    _scaleOps.transposeAll(rootNote)\n    state.rootNote = 0\n    state.pendingRootNote = 0\n    if state.selectedNote ~= nil and state.selectedNote ~= 13 then\n        state.selectedNote = (state.selectedNote + rootNote) % 12\n    end\n    markSequenceDirty()\n    savePreferences()\n    playKeyCenterPreview()\n    refreshDrone()\n    _syncKeyLineInTextBuffer()\nend\n\nfunction adjustKeyNote(direction)\n    local mode = state.solfegeScale or \"major\"\n    local notes = _scaleOps.notesByMode[mode] or _scaleOps.notesByMode.major\n    local scaleNotes = {}\n    for _, n in ipairs(notes) do\n        if n < 12 then scaleNotes[#scaleNotes + 1] = n end\n    end\n    local curIdx = 1\n    for i, n in ipairs(scaleNotes) do\n        if n == (state.keyNote or 0) then curIdx = i; break end\n    end\n    curIdx = curIdx + direction\n    if curIdx < 1 then curIdx = #scaleNotes\n    elseif curIdx > #scaleNotes then curIdx = 1 end\n    state.keyNote = scaleNotes[curIdx]\n    savePreferences()\n    playPreferencePreview(state.keyNote, state.keyOctave)\nend\n\nfunction adjustKeyOctave(direction)\n    local nextOctave = state.keyOctave + direction\n    state.keyOctave = math.min(7, math.max(2, nextOctave))\n    savePreferences()\n    playPreferencePreview(state.keyNote, state.keyOctave)\nend\n\nfunction adjustDroneNote(direction)\n    state.droneNoteSelection = state.droneNoteSelection + direction\n    if state.droneNoteSelection < 0 then\n        state.droneNoteSelection = 12\n    elseif state.droneNoteSelection > 12 then\n        state.droneNoteSelection = 0\n    end\n    savePreferences()\n    refreshDrone()\n    local previewNote, previewOctave = transposeNoteForKey(state.droneNoteSelection, state.droneOctave)\n    playPreferencePreview(previewNote, previewOctave)\nend\n\nfunction adjustDroneOctave(direction)\n    local nextOctave = state.droneOctave + direction\n    state.droneOctave = math.min(7, math.max(2, nextOctave))\n    savePreferences()\n    refreshDrone()\n    if state.droneNoteSelection >= 0 then\n        local previewNote, previewOctave = transposeNoteForKey(state.droneNoteSelection, state.droneOctave)\n        playPreferencePreview(previewNote, previewOctave)\n    end\nend\n\nfunction setDroneEnabled(enabled)\n    state.droneEnabled = enabled == true\n    if state.droneEnabled and (state.droneNoteSelection == nil or state.droneNoteSelection < 0) then\n        state.droneNoteSelection = 0\n    end\n    savePreferences()\n    refreshDrone()\nend\n\nfunction adjustDefaultTempo(direction)\n    state.defaultTempo = math.min(990, math.max(1, state.defaultTempo + (direction * 5)))\n    setTempo(state.defaultTempo)\n    savePreferences()\nend\n\nfunction adjustStepBeats(direction)\n    local options = core.stepBeatsOptions\n    local currentIndex = core.getStepBeatsOptionIndex(state.stepBeats)\n    local newIndex = currentIndex + direction\n    if newIndex < 1 then\n        newIndex = #options\n    elseif newIndex > #options then\n        newIndex = 1\n    end\n    setStepBeats(options[newIndex])\n    savePreferences()\nend\n\nfunction getHandImage(noteIndex)\n    if noteIndex == nil or noteIndex == 13 then\n        return nil\n    end\n    local handKey = core.noteToHandKey[noteIndex]\n    if not handKey then\n        return nil\n    end\n    if handImages and not handImages[handKey] and gfx then\n        handImages[handKey] = gfx.loadImage(\"hands/\" .. handKey)\n    end\n    return handImages and handImages[handKey]\nend\n\n\nfunction advanceSequencer()\n    if not state.isPlaying then return end\n\n    local playbackSequenceIndices = getSequentialPlaybackSequenceIndices()\n    local syllableRecent = state._syllableAddedAt and (os.clock() - state._syllableAddedAt < 2)\n    local textEditorOpen = state.showSolfegeTextInput ~= false\n    if #playbackSequenceIndices == 0 then\n        -- Don't stop if user is actively editing — sequence may be momentarily empty\n        if state.solfegeInputActive or state._syllableBtnHeld or syllableRecent or textEditorOpen or state.syllableDropdownOpen or not state.editCursorFollowsPlayhead then\n            return\n        end\n        stopPlayback()\n        return\n    end\n\n    local activeSequenceIndex = playbackSequenceIndices[1]\n    for _, sequenceIndex in ipairs(playbackSequenceIndices) do\n        if sequenceIndex == state.currentPlaybackSequenceIndex then\n            activeSequenceIndex = sequenceIndex\n            break\n        end\n    end\n    state.currentPlaybackSequenceIndex = activeSequenceIndex\n\n    local playbackSequence = state.sequences[activeSequenceIndex]\n    local activeSequenceLength = state.sequenceLengths[activeSequenceIndex] or 0\n    if activeSequenceLength <= 0 then\n        return\n    end\n\n    if state.playbackPosition > activeSequenceLength then\n        state.playbackPosition = 1\n    end\n\n    state.currentPlaybackStep = state.playbackPosition\n    -- Auto-follow: keep edit cursor locked to playhead when user hasn't broken away\n    if state.editCursorFollowsPlayhead then\n        state.currentStep = state.currentPlaybackStep\n    end\n    _updateSolfegeStepHighlight()\n    -- Scroll horizontally to keep current playback step visible\n    do\n        local spr = state._gridStepsPerRow or 1\n        local vc  = state._gridVisibleCols  or 1\n        local col = (state.currentPlaybackStep - 1) % spr\n        local sc  = state.gridScrollCol or 0\n        if col < sc then\n            state.gridScrollCol = col\n        elseif col >= sc + vc then\n            state.gridScrollCol = col - vc + 1\n        end\n    end\n\n\n    local playedStepLength = 1\n    if not state.singSolfegeMode then\n        local voice = sequenceVoices[activeSequenceIndex] or primaryVoice\n        local stepData = playbackSequence[state.currentPlaybackStep]\n        if stepData then\n            playedStepLength = core.getStepLength(stepData)\n            local octaveTranspose = state.sequenceOctaveTranspose[activeSequenceIndex] or 0\n            local randomOctaveOffset = 0\n            local stepGate = stepData.gate or 0.9\n            if state.randomizeOctavePlayback then\n                randomOctaveOffset = math.random(0, 2) - 1\n            end\n\n            local semitoneShift = state.solfegeTranspose or 0\n\n            -- Resolve random alternatives at playback time\n            if stepData.randomAlternatives then\n                local alt = stepData.randomAlternatives[math.random(#stepData.randomAlternatives)]\n                stepData = {\n                    note = alt.note,\n                    octave = alt.octave or stepData.octave,\n                    length = alt.length or stepData.length,\n                    gate = stepData.gate,\n                }\n            end\n\n            if stepData.muted then\n                stopAllVoices()\n                sendMidiNoteOff()\n            elseif core.isChord(stepData) then\n                local transposedNotes = {}\n                local hasPlayableNote = false\n                for _, noteData in ipairs(stepData.notes) do\n                    local transposedNote, transposedOctave = transposeNoteForKey(noteData.note, noteData.octave)\n                    if semitoneShift ~= 0 and transposedNote and transposedNote ~= 13 then\n                        transposedNote = transposedNote + semitoneShift\n                        while transposedNote > 12 do transposedNote = transposedNote - 12; transposedOctave = transposedOctave + 1 end\n                        while transposedNote < 0 do transposedNote = transposedNote + 12; transposedOctave = transposedOctave - 1 end\n                    end\n                    transposedOctave = math.max(2, math.min(7, transposedOctave + octaveTranspose + randomOctaveOffset))\n                    table.insert(transposedNotes, {note = transposedNote, octave = transposedOctave})\n                    if transposedNote ~= nil and transposedNote ~= 13 and core.noteFreqs[transposedNote] ~= nil then\n                        hasPlayableNote = true\n                    end\n                end\n                if hasPlayableNote then\n                    playChord(transposedNotes, state.currentPlaybackStep, voice, nil, playedStepLength, stepGate)\n                else\n                    stopAllVoices()\n                end\n            else\n                local transposedNote, transposedOctave = transposeNoteForKey(stepData.note, stepData.octave)\n                if semitoneShift ~= 0 and transposedNote and transposedNote ~= 13 then\n                    transposedNote = transposedNote + semitoneShift\n                    while transposedNote > 12 do transposedNote = transposedNote - 12; transposedOctave = transposedOctave + 1 end\n                    while transposedNote < 0 do transposedNote = transposedNote + 12; transposedOctave = transposedOctave - 1 end\n                end\n                transposedOctave = math.max(2, math.min(7, transposedOctave + octaveTranspose + randomOctaveOffset))\n                if transposedNote ~= nil and transposedNote ~= 13 and core.noteFreqs[transposedNote] ~= nil then\n                    playNote(transposedNote, transposedOctave, state.currentPlaybackStep, voice, nil, playedStepLength, stepGate)\n                else\n                    stopAllVoices()\n                end\n            end\n        else\n            stopAllVoices()\n        end\n    end\n\n    -- Advance by integer slot count; fractional length only affects clock timing\n    state.playbackPosition = state.playbackPosition + math.ceil(playedStepLength)\n\n    local stepLoopStart, stepLoopEnd = getEnabledStepLoopRange(activeSequenceIndex, activeSequenceLength)\n    if stepLoopStart and stepLoopEnd then\n        if state.playbackPosition > stepLoopEnd then\n            state.playbackPosition = stepLoopStart\n        end\n        state.currentPlaybackSequenceIndex = activeSequenceIndex\n        return playedStepLength\n    end\n\n    if state.playbackPosition > activeSequenceLength then\n        local currentOrderIndex = 1\n        for orderIndex, sequenceIndex in ipairs(playbackSequenceIndices) do\n            if sequenceIndex == activeSequenceIndex then\n                currentOrderIndex = orderIndex\n                break\n            end\n        end\n\n        local nextOrderIndex = currentOrderIndex + 1\n        if nextOrderIndex > #playbackSequenceIndices then\n            -- Keep looping when user is actively editing (syllable button held, solfege input active, text editor open, etc.)\n            if state.loopPlayback or state.solfegeInputActive or state._syllableBtnHeld or syllableRecent or textEditorOpen or state.syllableDropdownOpen or not state.editCursorFollowsPlayhead then\n                state.currentPlaybackSequenceIndex = playbackSequenceIndices[1]\n                state.playbackPosition = 1\n                return playedStepLength\n            end\n            state.isPlaying = false\n            state._playbackEndingGrace = true\n            playbackClock.nextStepAt = nil\n            timerAdapter.newOneShot(math.floor(state.stepDuration * playedStepLength), function()\n                state._playbackEndingGrace = false\n                if not state.isPlaying then\n                    stopPlayback({skipPreview = true})\n                end\n            end)\n            return playedStepLength\n        end\n\n        state.currentPlaybackSequenceIndex = playbackSequenceIndices[nextOrderIndex]\n        state.playbackPosition = 1\n    end\n    return playedStepLength\nend\n\n\nstartDrone = function()\n    -- Disable drone in sing solfege mode - continuous audio would interfere with pitch detection\n    if state.audioMuted then\n        return\n    end\n    if state.singSolfegeMode then\n        return\n    end\n    if not state.droneEnabled then\n        return\n    end\n    if state.droneNoteSelection == nil or state.droneNoteSelection < 0 then\n        return\n    end\n    local droneNote, droneOctave = transposeNoteForKey(state.droneNoteSelection, state.droneOctave or 4)\n    local baseFreq = core.noteFreqs[droneNote]\n    if not baseFreq or not droneOctave then\n        return\n    end\n    local octaveMultiplier = 2 ^ (droneOctave - 4)\n    local freq = baseFreq * octaveMultiplier\n    droneSynth:stop()\n    if droneSynth.playLoop then\n        droneSynth:playLoop(freq, 0.6)\n    else\n        droneSynth:playNote(freq, 0.6, 120)\n    end\nend\n\nstopDrone = function()\n    droneSynth:stop()\nend\n\nrefreshDrone = function()\n    stopDrone()\n    startDrone()\nend\n\n-- MIDI realtime/clock helpers (global to stay under Lua's 200 local limit).\n_midiClock = { accumMs = 0, lastTime = nil }\n\nfunction _midiClock.sendRealTime(byte)\n    if midiOut and midiOut.sendRealTime then\n        midiOut.sendRealTime(byte)\n    end\nend\n\nfunction startPlayback()\n    if getPlaybackLength() == 0 then return end -- Don't play empty sequence\n    do\n        local _df = io.open(\"/tmp/solfege_playnote_debug.log\", \"a\")\n        if _df then\n            _df:write(string.format(\"\\n=== PLAYBACK START %s ===\\n\", os.date(\"%H:%M:%S\")))\n            _df:close()\n        end\n    end\n    state._playbackEndingGrace = false\n    local resuming = state.isPaused\n    state.isPlaying = true\n    state.isPaused = false\n    -- Reset clock accumulator and send MIDI Start or Continue\n    _midiClock.accumMs = 0\n    _midiClock.lastTime = nil\n    if resuming then\n        _midiClock.sendRealTime(0xFB)   -- MIDI Continue\n    else\n        _midiClock.sendRealTime(0xFA)   -- MIDI Start\n    end\n    if not resuming then\n        state.currentPlaybackSequenceIndex = state.activeSequenceIndex or 1\n        local loopStart = select(1, getEnabledStepLoopRange(state.currentPlaybackSequenceIndex, state.sequenceLength or 0))\n        state.playbackPosition = loopStart or 1\n        state.currentPlaybackStep = state.playbackPosition\n        state.singSolfegeLastAdvanceStep = nil\n        state.singSolfegeRestStep = false\n        state.singSolfegeStepResults = {}\n        singSolfegeRestAdvanceAt = nil\n    end\n    setAutoLockDisabled(true)\n    setPlaybackRootNote()\n    setAutoLockDisabled(true)\n    -- Mute mic before any audio plays to prevent pickup\n    if state.singSolfegeMode and useCPitchDetector and pitchDetector.mute then\n        pitchDetector.mute()\n        pitchDetectorMuted = true\n        pitchFeedbackDeafUntil = getCurrentTimeMilliseconds() + pitchFeedbackDeafDurationMs\n    end\n    local firstPlayableStepAudio = false\n    do\n        local playbackSequenceIndices = getSequentialPlaybackSequenceIndices()\n        local firstSequenceIndex = playbackSequenceIndices[1] or (state.activeSequenceIndex or 1)\n        local firstSequence = state.sequences[firstSequenceIndex] or state.sequence\n        local firstStepData = firstSequence and firstSequence[1]\n        firstPlayableStepAudio = stepHasPlayableAudio(firstStepData)\n    end\n    if not resuming then\n        if state.singSolfegeMode then\n            resetSingSolfegeOctaveOffset()\n            if firstPlayableStepAudio then\n                playSingSolfegeRoot()\n            end\n        elseif state.playKeyBeforeSteps and firstPlayableStepAudio then\n            playNote(state.keyNote, getSequencerRootOctave(), nil, primaryVoice, true)\n        end\n    end\n    local now = getCurrentTimeMilliseconds()\n    local firstStepDelay\n    if not resuming and (state.singSolfegeMode or state.playKeyBeforeSteps) then\n        local leadInBeats = state.keyLeadInBeats or 1\n        local leadInDuration = state.stepDuration * leadInBeats\n        firstStepDelay = leadInDuration + state.stepDuration\n    else\n        firstStepDelay = 10\n    end\n    playbackClock.nextStepAt = now + firstStepDelay\n    -- Start drone after first step fires so its attack doesn't overlap the first note\n    timerAdapter.newOneShot(firstStepDelay, function()\n        if state.isPlaying and firstPlayableStepAudio then refreshDrone() end\n    end)\n    schedulePlaybackStopTimer()\nend\n\nfunction pausePlayback()\n    if not state.isPlaying then return end\n    state.isPlaying = false\n    state.isPaused = true\n    _midiClock.sendRealTime(0xFC)   -- MIDI Stop (pause = stop clock, Continue resumes)\n    singSolfegeRootHoldRepeatAt = nil\n    setAutoLockDisabled(false)\n    stopDrone()\n    playbackClock.nextStepAt = nil\n    if playbackStopTimer then\n        playbackStopTimer:remove()\n        playbackStopTimer = nil\n    end\nend\n\nfunction stopPlayback(opts)\n    state.isPlaying = false\n    state._syllableAddedAt = nil\n    state.isPaused = false\n    state.midiLiveRecord = false\n    _midiClock.sendRealTime(0xFC)   -- MIDI Stop\n    singSolfegeRootHoldRepeatAt = nil\n    setAutoLockDisabled(false)\n    if not (opts and opts.skipPreview) then\n        local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n        if host and host.audio and host.audio.haltAll then\n            host.audio.haltAll()\n        end\n    end\n    stopDrone()\n    setAutoLockDisabled(false)\n    playbackClock.nextStepAt = nil\n    if playbackStopTimer then\n        playbackStopTimer:remove()\n        playbackStopTimer = nil\n    end\n    state.playbackPosition = 1\n    state.currentPlaybackStep = 1\n    state.currentPlaybackSequenceIndex = 1\n    state.editCursorFollowsPlayhead = true\n    state.editCursorManuallySince = nil\n    state.singSolfegeLastAdvanceStep = nil\n    state.singSolfegeRestStep = false\n    state.singSolfegeStepResults = {}\n    singSolfegeRestAdvanceAt = nil\n    if not (opts and opts.skipPreview) then\n        playCurrentStepPreview()\n    end\nend\n\n-- MIDI Clock transmission (24 PPQN).\n-- Accumulates elapsed ms each frame and fires 0xF8 pulses at the correct interval.\n-- State (_midiClock.accumMs / lastTime) is declared above with sendRealTime.\nfunction _midiClock.tick()\n    if not midiOut or not midiOut.sendRealTime then return end\n    if not state.isPlaying then return end\n\n    local now = getCurrentTimeMilliseconds()\n    if _midiClock.lastTime == nil then\n        _midiClock.lastTime = now\n        return\n    end\n\n    local elapsed = now - _midiClock.lastTime\n    _midiClock.lastTime = now\n\n    -- Interval between clock pulses in ms: 60000 / (BPM * 24)\n    local intervalMs = 60000 / ((state.tempo or 120) * 24)\n    _midiClock.accumMs = _midiClock.accumMs + elapsed\n\n    while _midiClock.accumMs >= intervalMs do\n        midiOut.sendRealTime(0xF8)   -- MIDI Timing Clock\n        _midiClock.accumMs = _midiClock.accumMs - intervalMs\n    end\nend\n\n-- Receive external MIDI Clock and sync app tempo to it.\nfunction _midiClock.tickIn()\n    if not midiOut or not midiOut.getClockBPM then return end\n    -- Don't override tempo for 3 s after a manual user change\n    if getCurrentTimeMilliseconds() < _midiClockManualOverrideUntil then return end\n    local extBpm = midiOut.getClockBPM()\n    if not extBpm then return end\n    local rounded = math.floor(extBpm + 0.5)\n    local clamped = math.max(1, math.min(990, rounded))\n    if clamped ~= state.tempo then\n        _midiClockApplying = true\n        setTempo(clamped)\n        _midiClockApplying = false\n    end\nend\n\n-- ===== SPELL CHECK =====\n_spellDict      = nil   -- hash set of lowercase words once loaded\n_spellDictFile  = nil   -- open file handle while loading progressively\n_spellDictLoaded = false\n_spellCheckPending = false\n_liveApplyPending = false\n_liveApplyCountdown = 0\n_DEBOUNCE_FRAMES = 12\n_liveApplyLastBuf = nil\n_liveApplyLastMode = nil\nSPELL_WORDS_PER_FRAME = 8000  -- words to index per frame (~30 frames to load)\n\n_openWithPollFrame = 0\n\nfunction _startSpellDictLoad()\n    if _spellDictLoaded or _spellDictFile then return end\n    local f = io.open(\"/usr/share/dict/words\", \"r\")\n    _spellDict = {}\n    if f then\n        _spellDictFile = f\n    else\n        _spellDictLoaded = true  -- no dict available; check will return no errors\n    end\nend\n\nfunction _runSpellCheck()\n    if not _spellDictLoaded then return end\n    _spellCheckPending = false\n    local textMode = state.solfegeTextMode or \"both\"\n    if textMode == \"steps\" then\n        state._solfegeSpellErrors = {}; return\n    end\n    local buf = state.solfegeInputBuffer or \"\"\n    local toCheck = {}\n    if textMode == \"lyrics\" then\n        for startPos, word in buf:gmatch(\"()(%S+)\") do\n            if word ~= \"||\" and word ~= \"|\" and word ~= \"--\"\n               and not word:match(\"^[Kk]ey:\") and not word:match(\"^[Ss]cale:\")\n               and not word:match(\"^[Bb][Pp][Mm]:\") and not word:match(\"^[Oo]ct:\") then\n                toCheck[#toCheck+1] = {word=word, bufStart=startPos, bufEnd=startPos+#word-1}\n            end\n        end\n    else\n        for startPos, token in buf:gmatch(\"()(%S+)\") do\n            local pipeIdx = token:find(\"|\", 1, true)\n            if pipeIdx and pipeIdx < #token then\n                local lyric = token:sub(pipeIdx+1)\n                local ls = startPos + pipeIdx\n                toCheck[#toCheck+1] = {word=lyric, bufStart=ls, bufEnd=ls+#lyric-1}\n            end\n        end\n    end\n    local errors = {}\n    for _, item in ipairs(toCheck) do\n        local w = item.word:lower():gsub(\"[^%a%'%-]\", \"\")\n        if w ~= \"\" and not _spellDict[w] then\n            errors[#errors+1] = {bufStart=item.bufStart, bufEnd=item.bufEnd}\n        end\n    end\n    state._solfegeSpellErrors = errors\nend\n\nfunction _requestLiveApply(immediate)\n    if immediate then\n        _liveApplyCountdown = 0\n        _liveApplyPending = true\n        _liveApplyLastBuf = nil\n    else\n        _liveApplyCountdown = _DEBOUNCE_FRAMES\n        _liveApplyPending = false\n    end\nend\n\nfunction _flushLiveApply()\n    if _liveApplyCountdown > 0 or _liveApplyPending then\n        _liveApplyCountdown = 0\n        _liveApplyPending = false\n        _liveApplyLastBuf = nil\n        liveApplySequenceFromText()\n    end\nend\n\nfunction tickLiveApply()\n    if _liveApplyPending then\n        _liveApplyPending = false\n        liveApplySequenceFromText()\n    elseif _liveApplyCountdown > 0 then\n        _liveApplyCountdown = _liveApplyCountdown - 1\n        if _liveApplyCountdown == 0 then\n            liveApplySequenceFromText()\n        end\n    end\nend\n\nfunction tickSpellCheck()\n    if not state.solfegeSpellCheck then return end\n    if _spellDict == nil then _startSpellDictLoad() end\n    -- Load a chunk of the dictionary per frame\n    if _spellDictFile then\n        for _ = 1, SPELL_WORDS_PER_FRAME do\n            local line = _spellDictFile:read(\"*l\")\n            if not line then\n                _spellDictFile:close(); _spellDictFile = nil\n                _spellDictLoaded = true; _spellCheckPending = true\n                break\n            end\n            _spellDict[line:lower()] = true\n        end\n    end\n    if _spellCheckPending and _spellDictLoaded then\n        _runSpellCheck()\n    end\nend\n-- ===== END SPELL CHECK =====\n\n-- ===== OPEN-WITH POLL =====\n-- Checks /tmp/solfege_open_with every ~90 frames for files handed off by the\n-- launcher when the app is already running (single-instance forward path).\nfunction tickOpenWith()\n    _openWithPollFrame = _openWithPollFrame + 1\n    if _openWithPollFrame < 90 then return end\n    _openWithPollFrame = 0\n    local sf = io.open(\"/tmp/solfege_open_with\", \"r\")\n    if not sf then return end\n    local path = sf:read(\"*l\")\n    sf:close()\n    if not path or path == \"\" then return end\n    os.remove(\"/tmp/solfege_open_with\")\n    local ext = path:match(\"%.([^%.]+)$\")\n    if ext then ext = ext:lower() end\n    if ext == \"mid\" or ext == \"midi\" then\n        importMidi(path)\n    elseif ext == \"solfege\" or ext == \"musicxml\" or ext == \"xml\" then\n        importMusicXML(path)\n    elseif ext == \"docx\" then\n        openDocxAsNewProject(path)\n    end\nend\n-- ===== END OPEN-WITH POLL =====\n\n-- ===== LYRIC EDITORS =====\nfunction getLyricEditorSpec(kind)\n    if kind == \"lyric_notes\" then\n        return {\n            getBuffer = function() return state.lyricNotesBuffer or \"\" end,\n            setBuffer = function(value) state.lyricNotesBuffer = value or \"\" end,\n            getCursor = function() return state.lyricNotesCursor end,\n            setCursor = function(value) state.lyricNotesCursor = value end,\n            getSelAnchor = function() return state.lyricNotesSelAnchor end,\n            setSelAnchor = function(value) state.lyricNotesSelAnchor = value end,\n            getSelFocus = function() return state.lyricNotesSelFocus end,\n            setSelFocus = function(value) state.lyricNotesSelFocus = value end,\n            maxLen = 400,\n            onChange = function() markSequenceDirty() end,\n        }\n    elseif kind == \"lyric_step\" then\n        return {\n            getBuffer = function() return state.lyricInputBuffer or \"\" end,\n            setBuffer = function(value) state.lyricInputBuffer = value or \"\" end,\n            getCursor = function() return state.lyricInputCursor end,\n            setCursor = function(value) state.lyricInputCursor = value end,\n            getSelAnchor = function() return state.lyricSelAnchor end,\n            setSelAnchor = function(value) state.lyricSelAnchor = value end,\n            getSelFocus = function() return state.lyricSelFocus end,\n            setSelFocus = function(value) state.lyricSelFocus = value end,\n            maxLen = 40,\n            onChange = function()\n                local si = state.lyricEditingStepIndex\n                if si and commitLyricBufferToStep(si) then\n                    markSequenceDirty()\n                end\n            end,\n        }\n    end\n    return nil\nend\n\nfunction textEditorNotifyChange(spec)\n    if spec and spec.onChange then\n        spec.onChange()\n    end\nend\n\nfunction textEditorCursorValue(kind, spec)\n    spec = spec or getLyricEditorSpec(kind)\n    if not spec then return 0, \"\", nil end\n    local buf = spec.getBuffer() or \"\"\n    local cur = spec.getCursor()\n    if cur == nil or cur > #buf then cur = #buf end\n    if cur < 0 then cur = 0 end\n    return cur, buf, spec\nend\n\nfunction textEditorSetCursor(kind, pos, spec)\n    spec = spec or getLyricEditorSpec(kind)\n    if not spec then return end\n    local buf = spec.getBuffer() or \"\"\n    local clamped = math.max(0, math.min(pos or #buf, #buf))\n    spec.setCursor(clamped >= #buf and nil or clamped)\nend\n\nfunction textEditorSelRange(kind)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return nil end\n    local a, f = spec.getSelAnchor(), spec.getSelFocus()\n    if a ~= nil and f ~= nil and a ~= f then\n        return math.min(a, f), math.max(a, f)\n    end\n    return nil\nend\n\nfunction textEditorClearSel(kind)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return end\n    spec.setSelAnchor(nil)\n    spec.setSelFocus(nil)\nend\n\nfunction textEditorDeleteSel(kind, notify)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return false end\n    local lo, hi = textEditorSelRange(kind)\n    if not lo then return false end\n    local buf = spec.getBuffer() or \"\"\n    spec.setBuffer(buf:sub(1, lo) .. buf:sub(hi + 1))\n    textEditorSetCursor(kind, lo, spec)\n    textEditorClearSel(kind)\n    if notify ~= false then\n        textEditorNotifyChange(spec)\n    end\n    return true\nend\n\nfunction textEditorCopy(kind)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return false end\n    local buf = spec.getBuffer() or \"\"\n    local lo, hi = textEditorSelRange(kind)\n    if lo then\n        clipboardWrite(buf:sub(lo + 1, hi))\n    else\n        clipboardWrite(buf)\n    end\n    return true\nend\n\nfunction textEditorCut(kind)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return false end\n    local buf = spec.getBuffer() or \"\"\n    if buf == \"\" then return false end\n    textEditorCopy(kind)\n    if not textEditorDeleteSel(kind, true) then\n        spec.setBuffer(\"\")\n        spec.setCursor(nil)\n        textEditorClearSel(kind)\n        textEditorNotifyChange(spec)\n    end\n    return true\nend\n\nfunction textEditorInsert(kind, text)\n    local spec = getLyricEditorSpec(kind)\n    if not spec or type(text) ~= \"string\" or text == \"\" then return false end\n    local cur, buf = textEditorCursorValue(kind, spec)\n    local lo, hi = textEditorSelRange(kind)\n    if lo then\n        cur = lo\n        buf = buf:sub(1, lo) .. buf:sub(hi + 1)\n    end\n    local available = (spec.maxLen or math.huge) - #buf\n    if available <= 0 then return false end\n    if #text > available then\n        text = text:sub(1, available)\n    end\n    spec.setBuffer(buf:sub(1, cur) .. text .. buf:sub(cur + 1))\n    textEditorSetCursor(kind, cur + #text, spec)\n    textEditorClearSel(kind)\n    textEditorNotifyChange(spec)\n    return true\nend\n\nfunction textEditorPaste(kind)\n    local text = clipboardRead()\n    if not text or text == \"\" then return false end\n    return textEditorInsert(kind, text)\nend\n\nfunction textEditorBackspace(kind)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return false end\n    if textEditorDeleteSel(kind, true) then return true end\n    local cur, buf = textEditorCursorValue(kind, spec)\n    if cur <= 0 then return false end\n    spec.setBuffer(buf:sub(1, cur - 1) .. buf:sub(cur + 1))\n    textEditorSetCursor(kind, cur - 1, spec)\n    textEditorNotifyChange(spec)\n    return true\nend\n\nfunction textEditorMoveCursor(kind, delta, extend)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return false end\n    local cur, buf = textEditorCursorValue(kind, spec)\n    if extend then\n        if spec.getSelAnchor() == nil then\n            spec.setSelAnchor(cur)\n        end\n        local newPos = math.max(0, math.min(cur + delta, #buf))\n        textEditorSetCursor(kind, newPos, spec)\n        local focus = spec.getCursor()\n        spec.setSelFocus((focus ~= nil) and focus or #buf)\n        return true\n    end\n\n    local lo, hi = textEditorSelRange(kind)\n    if lo then\n        textEditorSetCursor(kind, delta < 0 and lo or hi, spec)\n    else\n        textEditorSetCursor(kind, cur + delta, spec)\n    end\n    textEditorClearSel(kind)\n    return true\nend\n\nfunction textEditorSelectAll(kind)\n    local spec = getLyricEditorSpec(kind)\n    if not spec then return false end\n    local buf = spec.getBuffer() or \"\"\n    if #buf == 0 then return false end\n    spec.setSelAnchor(0)\n    spec.setSelFocus(#buf)\n    spec.setCursor(nil)\n    return true\nend\n\nfunction lyricNotesSelRange()\n    return textEditorSelRange(\"lyric_notes\")\nend\n\nfunction lyricNotesClearSel()\n    textEditorClearSel(\"lyric_notes\")\nend\n\nfunction lyricNotesDeleteSel()\n    return textEditorDeleteSel(\"lyric_notes\", true)\nend\n\nfunction copyLyricNotesSelection()\n    return textEditorCopy(\"lyric_notes\")\nend\n\nfunction cutLyricNotesSelection()\n    return textEditorCut(\"lyric_notes\")\nend\n\nfunction pasteIntoLyricNotesText()\n    return textEditorPaste(\"lyric_notes\")\nend\n\nfunction copyLyricStepSelection()\n    return textEditorCopy(\"lyric_step\")\nend\n\nfunction cutLyricStepSelection()\n    return textEditorCut(\"lyric_step\")\nend\n\nfunction pasteIntoLyricStepText()\n    return textEditorPaste(\"lyric_step\")\nend\n\nfunction textEditorPosFromSingleLine(lineText, relX)\n    local text = tostring(lineText or \"\")\n    local lo, hi = 0, #text\n    while lo < hi do\n        local mid = math.floor((lo + hi + 1) / 2)\n        if ui.measureText(text:sub(1, mid)) <= relX then lo = mid else hi = mid - 1 end\n    end\n    return lo\nend\n\n-- ===== SOLFEGE CONTEXT MENU =====\nCTX_MENU_W = 100\nCTX_ITEM_H = 22\nCTX_MENU_PADY = 4\n\nfunction _ctxMenuY(my, totalH, screenH)\n    -- If menu would overflow the bottom, flip it above the click point\n    local sh = screenH or 240\n    if my + totalH > sh then\n        return math.max(0, my - totalH)\n    end\n    return my\nend\n\nfunction showSolfegeCtxMenu(mx, my, screenH)\n    local selLo = solfegeSelRange()\n    local hasSel = (selLo ~= nil)\n    _ensureSolfegeTextHistory()\n    local hasUndo = #(state.solfegeTextUndoStack or {}) > 0\n    local hasRedo = #(state.solfegeTextRedoStack or {}) > 0\n    local totalH = CTX_MENU_PADY * 2 + 5 * CTX_ITEM_H\n    local menuY = _ctxMenuY(my, totalH, screenH)\n    local items = {\n        {label = \"Undo\",  action = \"undo\",  enabled = hasUndo},\n        {label = \"Redo\",  action = \"redo\",  enabled = hasRedo},\n        {label = \"Copy\",  action = \"copy\",  enabled = hasSel},\n        {label = \"Cut\",   action = \"cut\",   enabled = hasSel},\n        {label = \"Paste\", action = \"paste\", enabled = true},\n    }\n    for i, item in ipairs(items) do\n        item.bounds = {x = mx, y = menuY + CTX_MENU_PADY + (i-1)*CTX_ITEM_H, w = CTX_MENU_W, h = CTX_ITEM_H}\n    end\n    state._solfegeCtxMenu = {x = mx, y = menuY, w = CTX_MENU_W, h = totalH, items = items, context = \"solfege\"}\nend\n\nfunction showLyricNotesCtxMenu(mx, my, screenH)\n    local hasBuf = #(state.lyricNotesBuffer or \"\") > 0\n    local hasSel = lyricNotesSelRange() ~= nil\n    local totalH = CTX_MENU_PADY * 2 + 3 * CTX_ITEM_H\n    local menuY = _ctxMenuY(my, totalH, screenH)\n    local items = {\n        {label = \"Copy\",  action = \"copy\",  enabled = hasBuf or hasSel},\n        {label = \"Cut\",   action = \"cut\",   enabled = hasBuf or hasSel},\n        {label = \"Paste\", action = \"paste\", enabled = true},\n    }\n    for i, item in ipairs(items) do\n        item.bounds = {x = mx, y = menuY + CTX_MENU_PADY + (i-1)*CTX_ITEM_H, w = CTX_MENU_W, h = CTX_ITEM_H}\n    end\n    state._solfegeCtxMenu = {x = mx, y = menuY, w = CTX_MENU_W, h = totalH, items = items, context = \"lyric_notes\"}\nend\n\nfunction showLyricStepCtxMenu(mx, my, screenH)\n    local hasBuf = #(state.lyricInputBuffer or \"\") > 0\n    local totalH = CTX_MENU_PADY * 2 + 3 * CTX_ITEM_H\n    local menuY = _ctxMenuY(my, totalH, screenH)\n    local items = {\n        {label = \"Copy\",  action = \"copy\",  enabled = hasBuf},\n        {label = \"Cut\",   action = \"cut\",   enabled = hasBuf},\n        {label = \"Paste\", action = \"paste\", enabled = true},\n    }\n    for i, item in ipairs(items) do\n        item.bounds = {x = mx, y = menuY + CTX_MENU_PADY + (i-1)*CTX_ITEM_H, w = CTX_MENU_W, h = CTX_ITEM_H}\n    end\n    state._solfegeCtxMenu = {x = mx, y = menuY, w = CTX_MENU_W, h = totalH, items = items, context = \"lyric_step\"}\nend\n-- ===== END CONTEXT MENU =====\n\n-- ===== SOLFEGE TEXT SELECTION & CLIPBOARD =====\n-- Selection anchors use \"absolute position\" (1 = before char 1, #buf+1 = after last char).\n-- state.solfegeSelAnchor / state.solfegeSelFocus hold these positions (nil = no selection).\n\nfunction solfegeSelRange()\n    local a = state.solfegeSelAnchor\n    local f = state.solfegeSelFocus\n    if not a or not f or a == f then return nil, nil end\n    if a <= f then return a, f else return f, a end\nend\n\nfunction solfegeClearSel()\n    state.solfegeSelAnchor = nil\n    state.solfegeSelFocus  = nil\nend\n\n-- Delete the selected range, update cursor. Returns true if anything was deleted.\nfunction solfegeDeleteSel()\n    local buf = state.solfegeInputBuffer or \"\"\n    local lo, hi = solfegeSelRange()\n    if not lo then return false end\n    local newBuf = buf:sub(1, lo - 1) .. buf:sub(hi)\n    state.solfegeInputBuffer = newBuf\n    state.solfegeInputCursor = (lo > #newBuf) and nil or lo\n    solfegeClearSel()\n    return true\nend\n\nfunction clipboardWrite(text)\n    local fh = io.popen(\"pbcopy\", \"w\")\n    if fh then fh:write(text or \"\"); fh:close() end\nend\n\nfunction clipboardRead()\n    local fh = io.popen(\"pbpaste\", \"r\")\n    if fh then\n        local text = fh:read(\"*a\")\n        fh:close()\n        return text or \"\"\n    end\n    return \"\"\nend\n\nfunction cmdChatSelRange()\n    local a = state.cmdChatSelAnchor\n    local f = state.cmdChatSelFocus\n    if a == nil or f == nil or a == f then return nil, nil end\n    local bufLen = #(state.cmdChatInputBuffer or \"\")\n    a = math.max(0, math.min(bufLen, a))\n    f = math.max(0, math.min(bufLen, f))\n    if a == f then return nil, nil end\n    if a <= f then return a, f else return f, a end\nend\n\nfunction cmdChatClearSel()\n    state.cmdChatSelAnchor = nil\n    state.cmdChatSelFocus = nil\nend\n\nfunction cmdChatDeleteSel()\n    local lo, hi = cmdChatSelRange()\n    if not lo then return false end\n    local buf = state.cmdChatInputBuffer or \"\"\n    state.cmdChatInputBuffer = buf:sub(1, lo) .. buf:sub(hi + 1)\n    state.cmdChatInputCursor = lo\n    cmdChatClearSel()\n    state.cmdChatHistoryIndex = nil\n    return true\nend\n\nfunction cmdChatReplaceSelection(text)\n    text = text or \"\"\n    local buf = state.cmdChatInputBuffer or \"\"\n    local cur = math.max(0, math.min(#buf, state.cmdChatInputCursor or #buf))\n    local lo, hi = cmdChatSelRange()\n    if lo then\n        state.cmdChatInputBuffer = buf:sub(1, lo) .. text .. buf:sub(hi + 1)\n        state.cmdChatInputCursor = lo + #text\n        cmdChatClearSel()\n    else\n        state.cmdChatInputBuffer = buf:sub(1, cur) .. text .. buf:sub(cur + 1)\n        state.cmdChatInputCursor = cur + #text\n    end\n    state.cmdChatHistoryIndex = nil\nend\n\nfunction cmdChatTextPosFromPoint(x, y)\n    local buf = state.cmdChatInputBuffer or \"\"\n    local metrics = state._cmdChatInputTextMetrics\n    if not metrics then return #buf end\n    local relX = x - (metrics.x or 0)\n    local displayText = metrics.displayText or \"\"\n    local charXs = metrics.charXs or {[0] = 0}\n    local bestOffset = #displayText\n    for ci = 0, #displayText - 1 do\n        local midX = ((charXs[ci] or 0) + (charXs[ci + 1] or 0)) / 2\n        if relX <= midX then bestOffset = ci; break end\n    end\n    return math.max(0, math.min(#buf, (metrics.hiddenLeft or 0) + bestOffset))\nend\n\nfunction copySolfegeSelection()\n    local buf = state.solfegeInputBuffer or \"\"\n    local lo, hi = solfegeSelRange()\n    if not lo then return false end\n    clipboardWrite(buf:sub(lo, hi - 1))\n    return true\nend\n\nfunction cutSolfegeSelection()\n    local buf = state.solfegeInputBuffer or \"\"\n    local lo, hi = solfegeSelRange()\n    if not lo then return false end\n    _pushSolfegeTextUndoState(true)\n    clipboardWrite(buf:sub(lo, hi - 1))\n    solfegeDeleteSel()\n    state.solfegeInputActive = true\n    state._solfegeLastCursorActivity = os.clock()\n    _requestLiveApply(true)\n    return true\nend\n\nfunction pasteIntoSolfegeText()\n    local text = clipboardRead()\n    if not text or #text == 0 then return false end\n    if state.solfegeInputBuffer == nil then\n        state.solfegeInputBuffer = state._solfegeSeqText or \"\"\n    end\n    _pushSolfegeTextUndoState(true)\n    if solfegeSelRange() then solfegeDeleteSel() end\n    local buf = state.solfegeInputBuffer or \"\"\n    local cur = state.solfegeInputCursor\n    if #buf + #text > 2000 then return false end\n    if cur then\n        state.solfegeInputBuffer = buf:sub(1, cur - 1) .. text .. buf:sub(cur)\n        state.solfegeInputCursor = cur + #text\n    else\n        state.solfegeInputBuffer = buf .. text\n    end\n    state.solfegeInputActive = true\n    state._solfegeLastCursorActivity = os.clock()\n    _requestLiveApply(true)\n    return true\nend\n\nfunction insertSolfegeTemplateText(text)\n    if not text or #text == 0 then return end\n    if state.solfegeInputBuffer == nil then\n        state.solfegeInputBuffer = state._solfegeSeqText or \"\"\n    end\n    _pushSolfegeTextUndoState(true)\n    if state._solfegeTemplateReplaceMode then\n        -- Replace mode: keep the Key: header line, replace the rest\n        local buf = state.solfegeInputBuffer or \"\"\n        local headerLine = buf:match(\"^([^\\n]*\\n?)\") or \"\"\n        if headerLine:match(\"^Key:\") then\n            state.solfegeInputBuffer = headerLine .. text\n        else\n            state.solfegeInputBuffer = text\n        end\n        state.solfegeInputCursor = nil\n    else\n        if solfegeSelRange() then solfegeDeleteSel() end\n        local buf = state.solfegeInputBuffer or \"\"\n        local cur = state.solfegeInputCursor\n        local prefix = (buf ~= \"\" and not buf:match(\"%s$\")) and \" \" or \"\"\n        if cur then\n            local before = buf:sub(1, cur - 1)\n            local after  = buf:sub(cur)\n            local insert = prefix .. text\n            state.solfegeInputBuffer = before .. insert .. after\n            state.solfegeInputCursor = cur + #insert\n        else\n            state.solfegeInputBuffer = buf .. prefix .. text\n        end\n    end\n    state.solfegeInputActive = true\n    state._solfegeLastCursorActivity = os.clock()\n    _requestLiveApply(true)\nend\n\nfunction saveCurrentAsUserTemplate(customName)\n    local buf = state.solfegeInputBuffer or state._solfegeSeqText or \"\"\n    -- Drop the header line (starts with \"Key:\"); take everything after first newline\n    local body = buf:match(\"\\n(.+)\") or (not buf:match(\"^Key:\") and buf or nil)\n    if not body then return end\n    body = body:match(\"^%s*(.-)%s*$\")\n    if body == \"\" then return end\n    if not state.userSolfegeTemplates then state.userSolfegeTemplates = {} end\n    local name\n    if customName and customName:match(\"%S\") then\n        name = customName:match(\"^%s*(.-)%s*$\")\n    else\n        local base = \"My Pattern\"\n        name = base\n        local taken = {}\n        for _, t in ipairs(state.userSolfegeTemplates) do taken[t.name] = true end\n        local n = 2\n        while taken[name] do name = base .. \" \" .. n; n = n + 1 end\n    end\n    table.insert(state.userSolfegeTemplates, {name = name, text = body})\n    state._solfegeAutocompleteCacheKey = nil\n    savePreferences()\n    return name, body\nend\n\nfunction saveSolfegeMelodyTemplate(customName, solfegeText)\n    local body = tostring(solfegeText or \"\"):match(\"^%s*(.-)%s*$\")\n    if body == \"\" then\n        return saveCurrentAsUserTemplate(customName)\n    end\n    if not state.userSolfegeTemplates then state.userSolfegeTemplates = {} end\n    local name = customName and customName:match(\"^%s*(.-)%s*$\") or \"\"\n    if name == \"\" then\n        local base = \"My Melody\"\n        name = base\n        local taken = {}\n        for _, t in ipairs(state.userSolfegeTemplates) do taken[t.name] = true end\n        local n = 2\n        while taken[name] do name = base .. \" \" .. n; n = n + 1 end\n    end\n    table.insert(state.userSolfegeTemplates, {name = name, text = body})\n    state._solfegeAutocompleteCacheKey = nil\n    savePreferences()\n    return name, body\nend\n\nfunction deleteUserSolfegeTemplate(index)\n    if state.userSolfegeTemplates then\n        table.remove(state.userSolfegeTemplates, index)\n        state._solfegeAutocompleteCacheKey = nil\n        savePreferences()\n    end\nend\n\nfunction renameUserSolfegeTemplate(index, newName)\n    newName = newName and newName:match(\"^%s*(.-)%s*$\") or \"\"\n    if newName == \"\" then return end\n    if state.userSolfegeTemplates and state.userSolfegeTemplates[index] then\n        state.userSolfegeTemplates[index].name = newName\n        state._solfegeAutocompleteCacheKey = nil\n        savePreferences()\n    end\nend\n\nfunction solfegeTextPosFromPoint(x, y)\n    local buf = state.solfegeInputBuffer or state._solfegeSeqText or \"\"\n    local visibleDls = state._solfegeDispLines\n    if not visibleDls or #visibleDls == 0 then return nil end\n    local textX = state._solfegeTextX or 0\n    local clickedDl = visibleDls[#visibleDls]\n    if y < (visibleDls[1].lineY or 0) then\n        clickedDl = visibleDls[1]\n    else\n        for _, dl in ipairs(visibleDls) do\n            if y < dl.lineY + dl.lineH then\n                clickedDl = dl\n                break\n            end\n        end\n    end\n    if not clickedDl then return nil end\n    local relX = x - textX\n    local lineText = clickedDl.text or \"\"\n    local cxs = clickedDl.charXs or {[0] = 0}\n    local bestOffset = #lineText\n    for ci = 0, #lineText - 1 do\n        local midX = ((cxs[ci] or 0) + (cxs[ci + 1] or 0)) / 2\n        if relX <= midX then bestOffset = ci; break end\n    end\n    local newCursor = (clickedDl.bufOfs or 1) + bestOffset\n    return (newCursor > #buf) and (#buf + 1) or newCursor\nend\n\nfunction moveSelectedSolfegeText(newAbsPos)\n    local buf = state.solfegeInputBuffer or \"\"\n    local lo, hi = solfegeSelRange()\n    if not lo then return false end\n    newAbsPos = math.max(1, math.min(#buf + 1, newAbsPos or hi))\n    if newAbsPos >= lo and newAbsPos <= hi then return false end\n    local selectedText = buf:sub(lo, hi - 1)\n    if selectedText == \"\" then return false end\n    _pushSolfegeTextUndoState(true)\n    local reduced = buf:sub(1, lo - 1) .. buf:sub(hi)\n    local insertPos = newAbsPos\n    if newAbsPos > hi then\n        insertPos = newAbsPos - (hi - lo)\n    end\n    insertPos = math.max(1, math.min(#reduced + 1, insertPos))\n    state.solfegeInputBuffer = reduced:sub(1, insertPos - 1) .. selectedText .. reduced:sub(insertPos)\n    state.solfegeSelAnchor = insertPos\n    state.solfegeSelFocus = insertPos + #selectedText\n    local cursorPos = insertPos + #selectedText\n    local newBuf = state.solfegeInputBuffer or \"\"\n    state.solfegeInputCursor = (cursorPos > #newBuf) and nil or cursorPos\n    state.solfegeInputActive = true\n    state._solfegeLastCursorActivity = os.clock()\n    _requestLiveApply(true)\n    return true\nend\n-- ===== END SOLFEGE TEXT SELECTION & CLIPBOARD =====\n\nlocal _webHost = rawget(_G, \"WebHost\")\n\nfunction updateFrame()\n    _flushSavePreferences()\n    if _webHost and _webHost.getSafeAreaBottom and ui.setSafeArea then\n        ui.setSafeArea(_webHost.getSafeAreaTop(), _webHost.getSafeAreaBottom())\n    end\n    if _oneDriveSignInPending then\n        local od = rawget(_G, \"_oneDriveBridge\")\n        if od then\n            local result = od.getSignInResult()\n            if result ~= \"pending\" then\n                _oneDriveSignInPending = nil\n                if result == \"ok\" then\n                    _oneDriveWelcomeRoot = od.getUserName() or \"OneDrive\"\n                    showImportMessage = true\n                    importMessageTimer = 240\n                    importMessageText = \"✓ OneDrive Connected\"\n                else\n                    showImportMessage = true\n                    importMessageTimer = 180\n                    importMessageText = \"⚠ OneDrive Sign-in Failed\"\n                end\n            end\n        end\n    end\n    if messageBridge then messageBridge.tick() end\n    if inputAdapter then inputAdapter.update() end\n    if _webHost and _webHost.showKeyboard then\n        local needKb = state.cmdChatInputActive\n            or state.solfegeInputActive\n            or state.lyricEditingStepIndex ~= nil\n            or state.lyricNotesEditingTokenIndex ~= nil\n            or state.scaleStepsInputOpen\n        if needKb and not state._webKeyboardShown then\n            _webHost.showKeyboard()\n            state._webKeyboardShown = true\n        elseif not needKb and state._webKeyboardShown then\n            _webHost.hideKeyboard()\n            state._webKeyboardShown = false\n        end\n        local b1 = state._cmdChatInputBounds or (not state.cmdChatOpen and state._cmdChatToggleBtn) or nil\n        local b2 = state._solfegeInputBounds\n        if b1 and b2 then\n            _webHost.setTextInputBounds(b1.x, b1.y, b1.w, b1.h, b2.x, b2.y, b2.w, b2.h)\n        elseif b1 then\n            _webHost.setTextInputBounds(b1.x, b1.y, b1.w, b1.h)\n        elseif b2 then\n            _webHost.setTextInputBounds(b2.x, b2.y, b2.w, b2.h)\n        else\n            _webHost.setTextInputBounds()\n        end\n    end\n    tickMidiInput()\n    _midiClock.tick()\n    _midiClock.tickIn()\n    setMirrorMute(isUsbConnected())\n    timerAdapter.update()\n    if state.earTrainingMode then core._et.tick() end\n    local frameNow = getCurrentTimeMilliseconds()\n    if _dialCC.noteOffAt and frameNow >= _dialCC.noteOffAt then\n        sendMidiNoteOff()\n    end\n    -- Real-time step stretch while a number key is held down (uses wall-clock ms)\n    if state._numKeyPressTime and state._numKeyPressStep then\n        local holdMs = getCurrentTimeMilliseconds() - state._numKeyPressTime\n        -- Linear index: equal dwell time per NOTE_LENGTHS slot across [0.25, 8].\n        -- Faster ramp so held step input reacts more immediately.\n        local t = (math.min(holdMs, heldStepInputMaxMs) / heldStepInputMaxMs) ^ 0.35\n        state._numKeyHoldT = t  -- expose to UI for progress bar\n        local si, ei = core._keyHoldStartIdx, core._keyHoldEndIdx\n        local newLen = math.min(heldStepInputMaxLength, core.NOTE_LENGTHS[si + math.min(ei - si, math.floor(t * (ei - si + 1)))])\n        local prevLen = state._numKeyPressLen or 1\n        if newLen ~= prevLen then\n            local step = state.sequence[state._numKeyPressStep]\n            if step then\n                if core.stretchStep(state, state._numKeyPressStep, newLen) then\n                    markSequenceDirty()\n                end\n            end\n            state._numKeyPressLen = newLen\n        end\n    end\n    if midiHoldInput.startTime and midiHoldInput.stepIndex then\n        local holdMs = getCurrentTimeMilliseconds() - midiHoldInput.startTime\n        local t = (math.min(holdMs, heldStepInputMaxMs) / heldStepInputMaxMs) ^ 0.35\n        local si, ei = core._keyHoldStartIdx, core._keyHoldEndIdx\n        local newLen = math.min(heldStepInputMaxLength, core.NOTE_LENGTHS[si + math.min(ei - si, math.floor(t * (ei - si + 1)))])\n        local prevLen = midiHoldInput.length or 1\n        if newLen ~= prevLen then\n            local step = state.sequence[midiHoldInput.stepIndex]\n            if step then\n                if core.stretchStep(state, midiHoldInput.stepIndex, newLen) then\n                    markSequenceDirty()\n                end\n            end\n            midiHoldInput.length = newLen\n        end\n    end\n\n    -- (reorder drag is promoted from holdPending only on mouse movement, not time)\n    -- Apply stretch from current mouse position every frame (handles sparse SDL motion events)\n    if stretchDrag.active and state.mouseX and stretchDrag.startMouseX then\n        local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n        if grid then\n            local mx = state.mouseX\n            local deltaPixels = mx - stretchDrag.startMouseX\n            -- Logarithmic scale: 40px = one doubling/halving.\n            local sign = stretchDrag.rightEdge and 1 or -1\n            local rawLen = (stretchDrag.startLen or 1) * (2 ^ (sign * deltaPixels / 40))\n            local maxLen = math.max(1, core.maxSteps - (stretchDrag.stepIndex or 1) + 1)\n            local newLen = math.max(0.125, math.min(maxLen, rawLen))\n            stretchDrag.previewLen = newLen\n            local step = state.sequence[stretchDrag.stepIndex]\n            if step and math.abs((step.length or 1) - newLen) > 0.0005 then\n                if core.stretchStep(state, stretchDrag.stepIndex, newLen, true) then\n                    markSequenceDirty()\n                end\n            end\n        end\n    end\n    -- Pre-warm audio cache one note per frame until complete\n    if audioPrewarmActive and not state.isPlaying then\n        tickAudioPrewarm()\n    end\n    if tickCmdChatAudition then\n        tickCmdChatAudition()\n    end\n    -- Spread deferred startup across frames so no single frame stalls.\n    -- Stage 1 (frame 1): load pitch detector, expose midiOut global\n    -- Stage 2 (frame 2): restore mic device + populate welcome files\n    -- Stage 3 (frame 3): start MIDI input + reconnect devices (waits for CoreMIDI bg init)\n    if rawget(_G, \"_deferredStartupPending\") then\n        _G._deferredStartupPending = false\n        if rawget(_G, \"_deferredStartupInit\") then\n            pcall(_G._deferredStartupInit)\n        end\n        _G._deferredStartupStage2 = true\n    elseif rawget(_G, \"_deferredStartupStage2\") then\n        _G._deferredStartupStage2 = nil\n        -- Re-evaluate pitch detector availability now that the library may be loaded.\n        if rawget(_G, \"pitchDetector\") and pitchDetector.isAvailable and pitchDetector.isAvailable() then\n            useCPitchDetector = true\n            state.useCPitchDetector = true\n        end\n        -- Re-run mic input device restore (skipped at load time when deferred).\n        if snd and snd.micinput and snd.micinput.setInputDevice then\n            if state.micInputDeviceName and state.micInputDeviceName ~= \"\" and snd.micinput.listInputDevices then\n                local devices = snd.micinput.listInputDevices() or {}\n                local i = 1\n                while i <= #devices do\n                    if devices[i].name == state.micInputDeviceName then\n                        snd.micinput.setInputDevice(devices[i].index)\n                        break\n                    end\n                    i = i + 1\n                end\n            else\n                snd.micinput.setInputDevice(0)\n            end\n        end\n        -- Populate welcome screen recent files (shell subprocess)\n        if _welcomeRecentFilesPending and showWelcomeScreen then\n            _welcomeRecentFilesPending = false\n            welcomeRecentFiles = findRecentMusicXMLFiles(50)\n        end\n        _G._deferredStartupStage3 = true\n    elseif rawget(_G, \"_deferredStartupStage3\") then\n        -- Poll: wait until CoreMIDI background init is done before touching MIDI\n        if midiOut and midiOut.isReady and not midiOut.isReady() then\n            -- not ready yet — try again next frame (no blocking)\n        else\n            _G._deferredStartupStage3 = nil\n            -- Start MIDI input\n            if rawget(_G, \"_deferredMidiStartInput\") and midiOut and midiOut.startInput then\n                _G._deferredMidiStartInput = nil\n                midiOut.startInput()\n                print(\"CoreMIDI virtual input port 'Solfege In' ready.\")\n            end\n            -- Re-run MIDI input device reconnect (midiOut was nil at load time).\n            if midiOut and midiOut.connectInputDevice then\n                local devices = midiOut.getInputDevices and midiOut.getInputDevices() or {}\n                local connected = false\n                if state.midiInDeviceName ~= \"\" then\n                    local matchedDevice = findMidiInputDeviceByName(devices, state.midiInDeviceName)\n                    if matchedDevice then\n                        local ok2 = midiOut.connectInputDevice(matchedDevice.index)\n                        if ok2 then\n                            print(\"Reconnected MIDI input: \" .. matchedDevice.name)\n                            connected = true\n                        else\n                            print(\"Failed to reconnect MIDI input: \" .. state.midiInDeviceName)\n                        end\n                    end\n                    if not connected then\n                        print(\"Saved MIDI device not available yet: \" .. state.midiInDeviceName)\n                    end\n                else\n                    local i = 1\n                    while i <= #devices do\n                        local name = devices[i].name or \"\"\n                        local isVirtual = name:find(\"IAC\") or name:find(\"Bus\") or name:find(\"Virtual\")\n                        if not isVirtual then\n                            local ok2 = midiOut.connectInputDevice(devices[i].index)\n                            if ok2 then\n                                state.midiInDeviceName = devices[i].name\n                                savePreferences()\n                                print(\"Auto-connected MIDI input: \" .. devices[i].name)\n                            end\n                            break\n                        end\n                        i = i + 1\n                    end\n                end\n            end\n        end\n    elseif _welcomeRecentFilesPending and showWelcomeScreen then\n        _welcomeRecentFilesPending = false\n        welcomeRecentFiles = findRecentMusicXMLFiles(50)\n    end\n    tickLiveApply()\n    pollCurwenEvents()\n    tickSpellCheck()\n    _updateSolfegeAutocomplete()\n    -- Keep scale syllables fresh for the all-options panel\n    do\n        local scale = state.solfegeScale or \"major\"\n        local notes = state.solfegeNotes or core.getSolfegeNotes(scale)\n        local positions = _SCALE_DIATONIC_POS[scale] or _SCALE_DIATONIC_POS.major\n        local cacheKey = scale .. \"\\31\" .. #notes .. \"\\31\" .. (notes[1] or \"\")\n        if state._acScaleSylsCacheKey ~= cacheKey then\n            local syls = {}\n            for _, pos in ipairs(positions) do\n                local syl = notes[pos + 1]\n                if syl and syl ~= \"Rest\" and syl ~= \"Do'\" then syls[#syls + 1] = syl end\n            end\n            state._acScaleSyls = syls\n            state._acScaleSylsCacheKey = cacheKey\n        end\n    end\n    tickOpenWith()\n    if state.singSolfegeAutoOctaveMessageUntil and state.singSolfegeAutoOctaveMessageUntil <= getCurrentTimeMilliseconds() then\n        state.singSolfegeAutoOctaveMessage = nil\n        state.singSolfegeAutoOctaveMessageUntil = nil\n    end\n    if state.isPlaying and playbackClock.nextStepAt and not state.singSolfegeMode then\n        local now = getCurrentTimeMilliseconds()\n        while playbackClock.nextStepAt and now >= playbackClock.nextStepAt do\n            local stepLengthMultiplier = advanceSequencer() or 1\n            if not state.isPlaying or not playbackClock.nextStepAt then\n                playbackClock.nextStepAt = nil\n                break\n            end\n            playbackClock.nextStepAt = playbackClock.nextStepAt + state.stepDuration * stepLengthMultiplier\n        end\n    end\n\n    if state.singSolfegeMode and state.isPlaying and inputAdapter and inputAdapter.isButtonPressed(InputAdapter.Button.SECONDARY) then\n        local now = getCurrentTimeMilliseconds()\n        if not singSolfegeRootHoldRepeatAt or now >= singSolfegeRootHoldRepeatAt then\n            playSingSolfegeRoot()\n            singSolfegeRootHoldRepeatAt = now + singSolfegeRootHoldRepeatMs\n        end\n    else\n        singSolfegeRootHoldRepeatAt = nil\n    end\n    \n    -- Update save message timer\n    if saveMessageTimer > 0 then\n        saveMessageTimer = saveMessageTimer - 1\n        if saveMessageTimer == 0 then\n            showSaveMessage = false\n        end\n    end\n    if importMessageTimer > 0 then\n        importMessageTimer = importMessageTimer - 1\n        if importMessageTimer == 0 then\n            showImportMessage = false\n        end\n    end\n\n    if autoSavePending then\n        autoSaveCountdown = autoSaveCountdown - 1\n        autoSaveMaxCountdown = autoSaveMaxCountdown - 1\n        if autoSaveCountdown <= 0 or autoSaveMaxCountdown <= 0 then\n            saveSequence(false)\n            autoSavePending = false\n            autoSaveMaxCountdown = 0\n        end\n    end\n\n    if state.micStepRecording and not canRecordMicStepInput() then\n        state.micStepRecording = false\n    end\n\n    updatePitchRecognition()\n    syncPitchTargetsForSingSolfege()\n    local singSolfegeStepData = getSingSolfegeStepData()\n    local isSingSolfegeRestStep = false\n    if state.singSolfegeMode then\n        if not singSolfegeStepData then\n            isSingSolfegeRestStep = true\n        else\n            local expectedNotes = getExpectedNotesForPitchMatch()\n            if #expectedNotes == 0 then\n                isSingSolfegeRestStep = true\n            end\n        end\n    end\n    state.singSolfegeRestStep = isSingSolfegeRestStep\n\n    local singSolfegeCurrentStep = state.isPlaying and state.currentPlaybackStep or state.currentStep\n    if state.micStepRecording and canRecordMicStepInput() and state.pitchHoldAchieved and not isSingSolfegeRestStep then\n        local noteToWrite = state.pitchDetectedNote\n        local octaveToWrite = state.pitchDetectedOctave\n        if noteToWrite ~= nil and noteToWrite ~= 13 and octaveToWrite ~= nil then\n            -- Convert chromatic detected note to solfege-relative (0=Do based on rootNote)\n            local rootNote = state.rootNote or 0\n            local relativeNote = (noteToWrite - rootNote + 12) % 12\n            -- If chromatic pitch is below the root, the solfege octave is one below\n            local octaveAdjust = (noteToWrite < rootNote) and -1 or 0\n            noteToWrite = relativeNote\n            octaveToWrite = math.max(2, math.min(7, octaveToWrite + octaveAdjust))\n            recordStepHistoryIfNeeded()\n            if state.currentStep == state.sequenceLength + 1 then\n                state.sequenceLength = state.sequenceLength + 1\n                state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n            end\n            local existingStep = state.sequence[state.currentStep]\n            state.sequence[state.currentStep] = {\n                note = noteToWrite,\n                octave = octaveToWrite,\n                lyric = existingStep and existingStep.lyric or nil,\n                length = existingStep and existingStep.length or nil,\n                gate = existingStep and existingStep.gate or nil,\n                muted = existingStep and existingStep.muted or nil,\n            }\n            markSequenceDirty()\n            state.currentStep = state.currentStep + 1\n            if state.currentStep > core.maxSteps then\n                state.currentStep = 1\n            end\n            state.pitchHoldMs = 0\n            state.pitchHoldAchieved = false\n            state.pitchHoldLastAt = getCurrentTimeMilliseconds()\n        end\n    elseif state.singSolfegeMode and state.pitchHoldAchieved and not isSingSolfegeRestStep then\n        if state.singSolfegeLastAdvanceStep ~= singSolfegeCurrentStep then\n            state.singSolfegeLastAdvanceStep = singSolfegeCurrentStep\n            if state.isPlaying then\n                advanceSequencer()\n                state.currentPlaybackStep = state.playbackPosition\n            else\n                state.currentStep = state.currentStep + 1\n                if state.currentStep > state.sequenceLength then\n                    state.currentStep = 1\n                end\n            end\n            -- Reset hold completely when advancing to prevent false chain-advancing\n            state.pitchHoldMs = 0\n            state.pitchHoldAchieved = false\n            state.pitchHoldLastAt = getCurrentTimeMilliseconds()\n        end\n    elseif not state.pitchHoldAchieved and not isSingSolfegeRestStep then\n        state.singSolfegeLastAdvanceStep = nil\n    end\n\n    if state.singSolfegeMode and isSingSolfegeRestStep then\n        local now = getCurrentTimeMilliseconds()\n        if not singSolfegeRestAdvanceAt then\n            singSolfegeRestAdvanceAt = now + getSingSolfegeRestAdvanceDelay()\n        elseif now >= singSolfegeRestAdvanceAt then\n            if state.singSolfegeLastAdvanceStep ~= singSolfegeCurrentStep then\n                state.singSolfegeLastAdvanceStep = singSolfegeCurrentStep\n                if state.isPlaying then\n                    advanceSequencer()\n                    state.currentPlaybackStep = state.playbackPosition\n                else\n                    state.currentStep = state.currentStep + 1\n                    if state.currentStep > state.sequenceLength then\n                        state.currentStep = 1\n                    end\n                end\n                state.pitchHoldMs = 0\n                state.pitchHoldAchieved = false\n                state.pitchHoldLastAt = getCurrentTimeMilliseconds()\n            end\n            singSolfegeRestAdvanceAt = nil\n        end\n    else\n        singSolfegeRestAdvanceAt = nil\n    end\n\n    if state.singSolfegeMode and isSingSolfegeRestStep and singSolfegeRestAdvanceAt then\n        local remaining = math.max(0, singSolfegeRestAdvanceAt - getCurrentTimeMilliseconds())\n        state.singSolfegeRestRemainingMs = remaining\n    else\n        state.singSolfegeRestRemainingMs = nil\n    end\n\n    local isMacDesktop = isRunningOnMacDesktop()\n\n    -- Lazy-init render context: stable fields set once, dynamic fields updated each frame.\n    -- Avoids allocating a 40-key table (and GC'ing it) at 60 fps.\n    if not _renderCtx then\n        _renderCtx = {\n            gfx = gfx,\n            core = core,\n            state = state,\n            platformName = platformAdapters.name,\n            solfegeNotes = solfegeNotes,\n            patternTypes = core.patternTypes,\n            getCurrentPatternList = getCurrentPatternList,\n            getSequenceMenuItems = getSequenceMenuItems,\n            getStepMenuItems = getStepMenuItems,\n            getHandImage = getHandImage,\n            getPlaybackSequence = getPlaybackSequence,\n            getPlaybackLength = getPlaybackLength,\n            recordingDuration = recordingDuration,\n            getCurrentTimeMilliseconds = getCurrentTimeMilliseconds,\n            getTemplateCategories = getTemplateCategories,\n            getTemplateList = getTemplateList,\n            getSelectedTemplate = getSelectedTemplate,\n            getModeList = getModeList,\n            midiActions = MIDI_ACTIONS,\n            hasMidiOut = true,\n            reorderDrag = reorderDrag,\n            stretchDrag = stretchDrag,\n            holdPending = holdPending,\n            holdThresholdS = HOLD_THRESHOLD_S,\n            lyricTokenDrag = lyricTokenDrag,\n            tokenizeLyricsText = LyricsImport.tokenizeLyricsText,\n        }\n    end\n    -- Update dynamic fields each frame\n    _renderCtx.isMacDesktop = isMacDesktop\n    _renderCtx.showRecordingScreen = showRecordingScreen\n    _renderCtx.showWelcomeScreen = showWelcomeScreen\n    _renderCtx.welcomeRecentFiles = welcomeRecentFiles\n    _renderCtx.oneDriveRoot = getOneDriveWelcomeRoot()\n    local _odBr = rawget(_G, \"_oneDriveBridge\")\n    _renderCtx.webOneDriveAvailable = _odBr and _odBr.isAvailable and _odBr.isAvailable() or false\n    _renderCtx.isRecording = isRecording\n    _renderCtx.recordingStartTime = recordingStartTime\n    _renderCtx.showSaveMessage = showSaveMessage\n    _renderCtx.showImportMessage = showImportMessage\n    _renderCtx.importMessageText = importMessageText\n    _renderCtx.musicXMLFileName = state.musicXMLFileName\n    _renderCtx.musicXMLFilePath = autoSaveMusicXMLFilename\n    _renderCtx.useSampleMode = useSampleMode\n    _renderCtx.recordedSample = recordedSample\n    _renderCtx.solfegeNotes = solfegeNotes\n\n    ui.render(_renderCtx)\n\n    -- Render the detached text input OS window (if open)\n    local _host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if _host and _host.textWindow and _host.textWindow.isOpen() then\n        state._textWindowWasOpen = true\n        if state.lyricNotesDetached and state.lyricNotesPanelOpen then\n            _host.textWindow.beginFrame()\n            ui.renderLyricNotesWindow(state, gfx, { tokenizeLyricsText = LyricsImport.tokenizeLyricsText })\n            _host.textWindow.endFrame()\n        elseif state.solfegeTextInputSide == \"window\" then\n            _host.textWindow.beginFrame()\n            ui.renderTextInputWindow(state, gfx, { tokenizeLyricsText = LyricsImport.tokenizeLyricsText })\n            _host.textWindow.endFrame()\n        else\n            _host.textWindow.close()\n        end\n    elseif _host and _host.textWindow and state.lyricNotesDetached and state.lyricNotesPanelOpen\n           and not _host.textWindow.isOpen() then\n        if state._textWindowWasOpen then\n            -- OS window was closed (native close button) — close the panel entirely\n            state.lyricNotesDetached = false\n            state.lyricNotesPanelOpen = false\n            state._textWindowWasOpen = false\n        else\n            _host.textWindow.open(\"Lyric Notes\", 420, 360)\n            state._textWindowWasOpen = true\n        end\n    elseif _host and _host.textWindow and state.solfegeTextInputSide == \"window\"\n           and not _host.textWindow.isOpen() then\n        if state._textWindowWasOpen then\n            state.solfegeTextInputSide = \"bottom\"\n            state._textWindowWasOpen = false\n            savePreferences()\n        else\n            _host.textWindow.open(\"Text Input\", 600, 480)\n            state._textWindowWasOpen = true\n        end\n    else\n        state._textWindowWasOpen = false\n    end\n\n    -- Render the Text Options OS window\n    local _optHost = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if _optHost and _optHost.optionsWindow then\n        if state._solfegeAllOptionsOpen then\n            if not _optHost.optionsWindow.isOpen() then\n                local logScale = (_optHost.logicalScale or 1)\n                _optHost.optionsWindow.open(\"Text Options\", 220, 420)\n            end\n            if _optHost.optionsWindow.isOpen() then\n                _optHost.optionsWindow.beginFrame()\n                ui.renderTextOptionsWindow(state, gfx)\n                _optHost.optionsWindow.endFrame()\n            end\n        else\n            if _optHost.optionsWindow.isOpen() then\n                _optHost.optionsWindow.close()\n            end\n        end\n    end\nend\n\nif isPlaydate then\n    function playdate.update()\n        updateFrame()\n    end\nelse\n    _G.solfegeSequencerUpdate = updateFrame\nend\n\n-- Breaks edit cursor away from auto-following the playhead.\n-- Called whenever the user explicitly navigates or edits a step during playback.\nfunction breakEditCursorFollow()\n    if state.isPlaying then\n        state.editCursorFollowsPlayhead = false\n        state.editCursorManuallySince = getCurrentTimeMilliseconds()\n    end\nend\n\nfunction adjustCurrentStepOctave(delta)\n    if state.syllableDropdownOpen then\n        adjustSyllableDropdownOctave(delta)\n        return\n    end\n    local newOctave = math.max(2, math.min(7, (state.currentOctave or 4) + delta))\n    if newOctave == (state.currentOctave or 4) then return end\n    state.currentOctave = newOctave\n    local step = state.sequence and state.sequence[state.currentStep]\n    if step then\n        if step.notes then\n            for _, nd in ipairs(step.notes) do\n                nd.octave = math.max(2, math.min(7, (nd.octave or 4) + delta))\n            end\n        elseif step.note ~= nil and step.note ~= 13 then\n            step.octave = newOctave\n        end\n        markSequenceDirty()\n        playCurrentStepPreview()\n    end\n    _syncKeyLineInTextBuffer()\nend\n\n-- Input handlers (using abstraction layer)\nfunction initializeInput()\n    -- Define input callbacks\n    local callbacks = {}\n\n    -- LEFT button handler\n    callbacks.onLeft = function()\n    if state.cmdChatInputActive then return end  -- handled by onRawKey\n    if showWelcomeScreen then return end\n    if state.bpmEditing or state.barEditing or state.beatEditing or state.seekEditing then return end\n    if state.lyricEditingStepIndex then return end  -- block navigation while editing lyric\n    if state.lyricNotesPanelOpen and not state.solfegeInputActive then return end  -- handled by onRawKey\n    if state.showingModeSelect then\n        local modes = getModeList()\n        state.selectedModeOption = state.selectedModeOption - 1\n        if state.selectedModeOption < 1 then state.selectedModeOption = #modes end\n        return\n    end\n    -- Handle template browser category navigation\n    if state.showingTemplateBrowser then\n        state.selectedTemplateCategory = state.selectedTemplateCategory - 1\n        if state.selectedTemplateCategory < 1 then\n            state.selectedTemplateCategory = #templateLibrary.categories\n        end\n        state.selectedTemplateIndex = 1 -- Reset to first template in category\n        return\n    end\n\n    if state.showingStepSelect then\n        local seqIndex = state.stepSelectSequenceIndex\n        local items = getStepMenuItems(seqIndex)\n        local selectedItem = items[state.selectedStepOption]\n        if inputAdapter.isButtonPressed(InputAdapter.Button.PRIMARY) then\n            local currentTranspose = state.sequenceOctaveTranspose[seqIndex] or 0\n            state.sequenceOctaveTranspose[seqIndex] = math.max(-3, currentTranspose - 1)\n            markSequenceDirty()\n        elseif selectedItem and selectedItem.stepIndex then\n            if core.moveStep(state, seqIndex, selectedItem.stepIndex, selectedItem.stepIndex - 1) then\n                state.selectedStepOption = math.max(1, state.selectedStepOption - 1)\n                markSequenceDirty()\n            end\n        elseif selectedItem and selectedItem.isSequenceMuteToggle then\n            state.sequenceMutes[seqIndex] = not state.sequenceMutes[seqIndex]\n            markSequenceDirty()\n            if state.isPlaying then\n                stopPlayback()\n                startPlayback()\n            end\n        else\n            -- Delete selected step\n            if selectedItem and selectedItem.stepIndex then\n                core.deleteStep(state, seqIndex, selectedItem.stepIndex)\n                markSequenceDirty()\n                -- Adjust selection\n                local newItems = getStepMenuItems(seqIndex)\n                if state.selectedStepOption > #newItems then\n                    state.selectedStepOption = #newItems\n                end\n            end\n        end\n        return\n    end\n\n    if state.showingSequenceSelect then\n        local items = getSequenceMenuItems()\n        local selectedItem = items[state.selectedSequenceOption]\n        if selectedItem and selectedItem.sequenceIndex then\n            -- Swap with previous sequence in menu\n            local prevItem = items[state.selectedSequenceOption - 1]\n            if prevItem and prevItem.sequenceIndex then\n                core.swapSequences(state, selectedItem.sequenceIndex, prevItem.sequenceIndex)\n                state.selectedSequenceOption = state.selectedSequenceOption - 1\n                markSequenceDirty()\n            end\n        end\n        return\n    end\n\n    if state.headerSelectionMode then\n        if not state.singSolfegeMode then\n            if state.headerSelection == 0 and state.sidebarOpen then\n                -- Cycle to previous sequence\n                local prev = state.activeSequenceIndex - 1\n                if prev < 1 then prev = core.maxSequences end\n                while prev ~= state.activeSequenceIndex and not state.sequences[prev] do\n                    prev = prev - 1\n                    if prev < 1 then prev = core.maxSequences end\n                end\n                if state.sequences[prev] then\n                    setActiveSequence(prev)\n                    markSequenceDirty()\n                end\n                return\n            end\n            if state.headerSelection == 4 then\n                adjustBarCount(-1)\n                return\n            end\n            if state.headerSelection == 10 then\n                adjustBeatCount(-1)\n                return\n            end\n            if state.headerSelection == 11 then\n                adjustStepBeats(-1)\n                savePreferences()\n                return\n            end\n            if state.headerSelection == 5 then\n                setTempo(state.tempo - 5)\n                savePreferences()\n                return\n            end\n            if state.headerSelection == 6 then\n                adjustRootNote(-1)\n                return\n            end\n            if state.headerSelection == 9 then\n                cycleTimeSignature(-1)\n                savePreferences()\n                return\n            end\n        end\n    end\n\n    if state.syllableDropdownOpen then\n        adjustSyllableDropdownOctave(-1)\n        return\n    end\n    \n    breakEditCursorFollow()\n    if state.chordMode then\n        -- In chord mode, navigate between notes in the chord\n        local step = state.sequence[state.currentStep]\n        if step and core.isChord(step) then\n            state.chordEditIndex = state.chordEditIndex - 1\n            if state.chordEditIndex < 1 then\n                state.chordEditIndex = #step.notes\n            end\n            -- Update selectedNote to match the current chord note\n            local currentNote = step.notes[state.chordEditIndex]\n            if currentNote then\n                state.selectedNote = currentNote.note\n                state.currentOctave = currentNote.octave\n            end\n        end\n    else\n        state.currentStep = state.currentStep - 1\n        local maxStep = math.max(1, (state.sequenceLength or 0) + 1)\n        if state.currentStep < 1 then\n            state.currentStep = maxStep\n        end\n        local _cs = state.sequence[state.currentStep]\n        if state.currentStep == (state.sequenceLength or 0) + 1 then\n            -- Add-step cursor: keep the current note/octave selection intact.\n        elseif not _cs or (not core.isChord(_cs) and _cs.note == nil) then\n            state.selectedNote = 0\n        else\n            if core.isChord(_cs) then\n                local fn = _cs.notes and _cs.notes[1]\n                if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n            elseif _cs.note ~= nil and _cs.note ~= 13 then\n                state.selectedNote = _cs.note\n                state.currentOctave = _cs.octave\n            end\n            playCurrentStepPreview()\n        end\n    end\n    _syncKeyLineInTextBuffer()\n    end\n\n    -- RIGHT button handler\n    callbacks.onRight = function()\n    if state.cmdChatInputActive then return end  -- handled by onRawKey\n    if showWelcomeScreen then return end\n    if state.bpmEditing or state.barEditing or state.beatEditing or state.seekEditing then return end\n    if state.lyricEditingStepIndex then return end  -- block navigation while editing lyric\n    if state.lyricNotesPanelOpen and not state.solfegeInputActive then return end  -- handled by onRawKey\n    if state.showingModeSelect then\n        local modes = getModeList()\n        state.selectedModeOption = state.selectedModeOption + 1\n        if state.selectedModeOption > #modes then state.selectedModeOption = 1 end\n        return\n    end\n    -- Handle template browser category navigation\n    if state.showingTemplateBrowser then\n        state.selectedTemplateCategory = state.selectedTemplateCategory + 1\n        if state.selectedTemplateCategory > #templateLibrary.categories then\n            state.selectedTemplateCategory = 1\n        end\n        state.selectedTemplateIndex = 1 -- Reset to first template in category\n        return\n    end\n\n    if state.showingStepSelect then\n        local seqIndex = state.stepSelectSequenceIndex\n        local items = getStepMenuItems(seqIndex)\n        local selectedItem = items[state.selectedStepOption]\n        if inputAdapter.isButtonPressed(InputAdapter.Button.PRIMARY) then\n            local currentTranspose = state.sequenceOctaveTranspose[seqIndex] or 0\n            state.sequenceOctaveTranspose[seqIndex] = math.min(3, currentTranspose + 1)\n            markSequenceDirty()\n        elseif selectedItem and selectedItem.stepIndex then\n            if core.moveStep(state, seqIndex, selectedItem.stepIndex, selectedItem.stepIndex + 1) then\n                state.selectedStepOption = math.min(#items, state.selectedStepOption + 1)\n                markSequenceDirty()\n            end\n        elseif selectedItem and selectedItem.isSequenceMuteToggle then\n            state.sequenceMutes[seqIndex] = not state.sequenceMutes[seqIndex]\n            markSequenceDirty()\n            if state.isPlaying then\n                stopPlayback()\n                startPlayback()\n            end\n        else\n            -- Make selected step a rest\n            if selectedItem and selectedItem.stepIndex then\n                core.setStepRest(state, seqIndex, selectedItem.stepIndex)\n                markSequenceDirty()\n            end\n        end\n        return\n    end\n\n    if state.showingSequenceSelect then\n        local items = getSequenceMenuItems()\n        local selectedItem = items[state.selectedSequenceOption]\n        if selectedItem and selectedItem.sequenceIndex then\n            -- Swap with next sequence in menu (skip \"Add New Sequence\" item)\n            local nextItem = items[state.selectedSequenceOption + 1]\n            if nextItem and nextItem.sequenceIndex then\n                core.swapSequences(state, selectedItem.sequenceIndex, nextItem.sequenceIndex)\n                state.selectedSequenceOption = state.selectedSequenceOption + 1\n                markSequenceDirty()\n            end\n        end\n        return\n    end\n\n    if state.headerSelectionMode then\n        if not state.singSolfegeMode then\n            if state.headerSelection == 0 and state.sidebarOpen then\n                -- Cycle to next sequence\n                local next = state.activeSequenceIndex + 1\n                if next > core.maxSequences then next = 1 end\n                while next ~= state.activeSequenceIndex and not state.sequences[next] do\n                    next = next + 1\n                    if next > core.maxSequences then next = 1 end\n                end\n                if state.sequences[next] then\n                    setActiveSequence(next)\n                    markSequenceDirty()\n                end\n                return\n            end\n            if state.headerSelection == 4 then\n                adjustBarCount(1)\n                return\n            end\n            if state.headerSelection == 10 then\n                adjustBeatCount(1)\n                return\n            end\n            if state.headerSelection == 11 then\n                adjustStepBeats(1)\n                savePreferences()\n                return\n            end\n            if state.headerSelection == 5 then\n                setTempo(state.tempo + 5)\n                savePreferences()\n                return\n            end\n            if state.headerSelection == 6 then\n                adjustRootNote(1)\n                return\n            end\n            if state.headerSelection == 9 then\n                cycleTimeSignature(1)\n                savePreferences()\n                return\n            end\n        end\n    end\n\n    if state.syllableDropdownOpen then\n        adjustSyllableDropdownOctave(1)\n        return\n    end\n    \n    breakEditCursorFollow()\n    if state.chordMode then\n        -- In chord mode, navigate between notes in the chord or add a new note\n        local step = state.sequence[state.currentStep]\n        if step and core.isChord(step) then\n            state.chordEditIndex = state.chordEditIndex + 1\n            if state.chordEditIndex > #step.notes then\n                -- Wrap around or stay at last note\n                if #step.notes < 3 then\n                    -- Try to add a new note\n                    state.chordEditIndex = #step.notes + 1\n                else\n                    state.chordEditIndex = 1\n                end\n            end\n            -- Update selectedNote to match the current chord note\n            if state.chordEditIndex <= #step.notes then\n                local currentNote = step.notes[state.chordEditIndex]\n                if currentNote then\n                    state.selectedNote = currentNote.note\n                    state.currentOctave = currentNote.octave\n                end\n            end\n        end\n    else\n        state.currentStep = state.currentStep + 1\n        -- Allow landing on sequenceLength+1 (the + button position); wrap after that\n        if state.currentStep > (state.sequenceLength or 0) + 1 then\n            state.currentStep = 1\n        end\n        local _cs = state.sequence[state.currentStep]\n        if state.currentStep == (state.sequenceLength or 0) + 1 then\n            -- Add-step cursor: keep the current note/octave selection intact.\n        elseif not _cs or (not core.isChord(_cs) and _cs.note == nil) then\n            state.selectedNote = 0\n        else\n            if core.isChord(_cs) then\n                local fn = _cs.notes and _cs.notes[1]\n                if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n            elseif _cs.note ~= nil and _cs.note ~= 13 then\n                state.selectedNote = _cs.note\n                state.currentOctave = _cs.octave\n            end\n            playCurrentStepPreview()\n        end\n    end\n    _syncKeyLineInTextBuffer()\n    end\n\n    -- UP button handler\n    local function placeSelectedNoteAtCurrentStep()\n    if state.sequenceLength >= core.maxSteps then return end -- Can't add more steps\n    if state.currentStep > core.maxSteps then return end -- Out of bounds\n    -- Fill any skipped slots with rests so there are no gaps\n    if state.currentStep > state.sequenceLength + 1 then\n        for fillIdx = state.sequenceLength + 1, state.currentStep - 1 do\n            state.sequence[fillIdx] = { note = 13, octave = 4 }\n        end\n        state.sequenceLength = state.currentStep - 1\n        state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n    end\n\n    if state.editMode == \"chord\" then\n        local step = state.sequence[state.currentStep]\n        if step and core.isChord(step) and state.chordEditIndex <= #step.notes then\n            core.updateChordNote(state, state.chordEditIndex, state.selectedNote, state.currentOctave)\n        else\n            if not step or (step.notes and #step.notes < 3) or (step.note ~= nil and not step.notes) then\n                if core.addNoteToChord(state, state.selectedNote, state.currentOctave) then\n                    step = state.sequence[state.currentStep]\n                    if step and step.notes then\n                        state.chordEditIndex = #step.notes\n                    end\n                end\n            end\n        end\n\n        local chordNotes = core.getChordNotes(state.sequence[state.currentStep])\n        local transposedNotes = {}\n        for _, noteData in ipairs(chordNotes) do\n            local transposedNote, transposedOctave = transposeNoteForKey(noteData.note, noteData.octave)\n            table.insert(transposedNotes, {note = transposedNote, octave = transposedOctave})\n        end\n        if #transposedNotes > 0 then\n            playChord(transposedNotes, nil, primaryVoice)\n        end\n    else\n        if state.currentStep == state.sequenceLength + 1 then\n            state.sequenceLength = state.sequenceLength + 1\n            state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n        end\n\n        local existingStep = state.sequence[state.currentStep]\n        state.sequence[state.currentStep] = {\n            note = state.selectedNote,\n            octave = state.currentOctave,\n            lyric = existingStep and existingStep.lyric or nil,\n            length = existingStep and existingStep.length or nil,\n            gate = existingStep and existingStep.gate or nil,\n            muted = existingStep and existingStep.muted or nil,\n        }\n        local transposedNote, transposedOctave = transposeNoteForKey(state.selectedNote, state.currentOctave)\n        playNote(transposedNote, transposedOctave, nil, primaryVoice)\n    end\n\n    markSequenceDirty()\n    end\n\n    local function setSelectedNoteFromPreviousStep(direction)\n    local previousStepIndex = (state.currentStep or 1) - 1\n    if previousStepIndex < 1 then\n        return false\n    end\n\n    local previousStep = state.sequence[previousStepIndex]\n    if not previousStep then\n        return false\n    end\n\n    local sourceNote = nil\n    local sourceOctave = nil\n    if core.isChord(previousStep) then\n        local firstNote = previousStep.notes and previousStep.notes[1]\n        if firstNote then\n            sourceNote = firstNote.note\n            sourceOctave = firstNote.octave\n        end\n    else\n        sourceNote = previousStep.note\n        sourceOctave = previousStep.octave\n    end\n\n    local nextNote, nextOctave = _scaleOps.stepNote(sourceNote, sourceOctave, direction or 1)\n    if nextNote == nil or nextOctave == nil then\n        return false\n    end\n\n    state.selectedNote = nextNote\n    state.currentOctave = nextOctave\n    return true\n    end\n\n    local function tryStretchCurrentStep(lengthDelta)\n    local step = state.sequence[state.currentStep]\n    if not step then\n        return false\n    end\n\n    local currentLen = step.length or 1\n    local targetLen\n    if lengthDelta > 0 then\n        local maxLen = math.max(1, core.maxSteps - state.currentStep + 1)\n        targetLen = math.min(maxLen, core.nextNoteLength(currentLen))\n    else\n        targetLen = core.prevNoteLength(currentLen)\n    end\n\n    if targetLen == currentLen then\n        return false\n    end\n\n    if core.stretchStep(state, state.currentStep, targetLen) then\n        markSequenceDirty()\n        return true\n    end\n\n    return false\n    end\n\n    local function stepCurrentStepNote(direction)\n    breakEditCursorFollow()\n    local step = state.sequence[state.currentStep]\n    local sourceNote = state.selectedNote\n    local sourceOctave = state.currentOctave\n\n    if step and core.isChord(step) then\n        local noteIndex = math.max(1, math.min(state.chordEditIndex or 1, #(step.notes or {})))\n        local noteData = step.notes and step.notes[noteIndex]\n        if noteData then\n            sourceNote = noteData.note\n            sourceOctave = noteData.octave\n        end\n\n        local nextNote, nextOctave = _scaleOps.stepNote(sourceNote, sourceOctave, direction or 1)\n        if nextNote == nil or nextOctave == nil then\n            return false\n        end\n\n        state.selectedNote = nextNote\n        state.currentOctave = nextOctave\n        core.updateChordNote(state, noteIndex, nextNote, nextOctave)\n        markSequenceDirty()\n        playCurrentStepPreview()\n        return true\n    end\n\n    if not step or (not core.isChord(step) and step.note == nil) then\n        placeSelectedNoteAtCurrentStep()\n        return true\n    end\n\n    if step.note == 13 then\n        -- Rest step: convert to Do (up) or last scale note (down) at current octave\n        local mode = state.solfegeScale or \"major\"\n        local scaleNotes = _scaleOps.notesByMode[mode] or _scaleOps.notesByMode.major\n        local dir = (direction or 1) >= 0 and 1 or -1\n        local nextNote = dir > 0 and scaleNotes[1] or scaleNotes[#scaleNotes]\n        local nextOctave = state.currentOctave\n        state.selectedNote = nextNote\n        state.currentOctave = nextOctave\n        step.note = nextNote\n        step.octave = nextOctave\n        markSequenceDirty()\n        playCurrentStepPreview()\n        return true\n    end\n\n    if step.note ~= nil and step.note ~= 13 then\n        sourceNote = step.note\n        sourceOctave = step.octave\n    end\n\n    local nextNote, nextOctave = _scaleOps.stepNote(sourceNote, sourceOctave, direction or 1)\n    if nextNote == nil or nextOctave == nil then\n        return false\n    end\n\n    state.selectedNote = nextNote\n    state.currentOctave = nextOctave\n    step.note = nextNote\n    step.octave = nextOctave\n    markSequenceDirty()\n    playCurrentStepPreview()\n    return true\n    end\n\n    callbacks.onUp = function()\n    if state.cmdChatInputActive then return end  -- handled by onRawKey\n    if state.solfegeInputActive then return end  -- onRawKey handles up/down when text editor is active\n    if showWelcomeScreen then\n        local maxFirst = math.max(1, #welcomeRecentFiles - (state._welcomeVisibleCount or 5) + 1)\n        state._welcomeFirstVisible = math.max(1, (state._welcomeFirstVisible or 1) - 1)\n        return\n    end\n    if state.bpmFocused then\n        setTempo(state.tempo + 5)\n        savePreferences()\n        return\n    end\n    if state.keyFocused then\n        adjustRootNote(1)\n        savePreferences()\n        return\n    end\n    -- Handle mode select screen navigation\n    if state.showingModeSelect then\n        local modes = getModeList()\n        state.selectedModeOption = state.selectedModeOption - 2\n        if state.selectedModeOption < 1 then\n            state.selectedModeOption = state.selectedModeOption + #modes\n        end\n        return\n    end\n\n    -- Handle template browser navigation\n    if state.showingTemplateBrowser then\n        local templates = getTemplateList()\n        state.selectedTemplateIndex = state.selectedTemplateIndex - 1\n        if state.selectedTemplateIndex < 1 then\n            state.selectedTemplateIndex = #templates\n        end\n        return\n    end\n\n    if state.showingStepSelect then\n        local items = getStepMenuItems(state.stepSelectSequenceIndex)\n        if #items > 0 then\n            state.selectedStepOption = state.selectedStepOption - 1\n            if state.selectedStepOption < 1 then\n                state.selectedStepOption = #items\n            end\n        end\n        return\n    end\n\n    if state.showingSequenceSelect then\n        local items = getSequenceMenuItems()\n        state.selectedSequenceOption = state.selectedSequenceOption - 1\n        if state.selectedSequenceOption < 1 then\n            state.selectedSequenceOption = #items\n        end\n        return\n    end\n\n    -- Handle MIDI In device picker navigation\n    if state.showingMidiInPicker then\n        state.midiInPickerSelection = state.midiInPickerSelection - 1\n        if state.midiInPickerSelection < 1 then\n            state.midiInPickerSelection = #state.midiInPickerDevices\n        end\n        return\n    end\n\n    -- Handle Mic Input device picker navigation\n    if state.showingMicInputPicker then\n        state.micInputPickerSelection = state.micInputPickerSelection - 1\n        if state.micInputPickerSelection < 1 then\n            state.micInputPickerSelection = #state.micInputPickerDevices\n        end\n        return\n    end\n\n    -- Handle Gamepad device picker navigation\n    if state.showingGamepadPicker then\n        state.gamepadPickerSelection = state.gamepadPickerSelection - 1\n        if state.gamepadPickerSelection < 1 then\n            state.gamepadPickerSelection = #state.gamepadPickerDevices\n        end\n        return\n    end\n\n    -- Handle MIDI Controls screen navigation\n    if state.showingMidiControls then\n        if not state.midiLearnMode then\n            state.midiControlsSelection = state.midiControlsSelection - 1\n            if state.midiControlsSelection < 1 then\n                state.midiControlsSelection = #MIDI_ACTIONS\n            end\n        end\n        return\n    end\n\n    if state.headerSelectionMode then\n        state.headerSelection = state.headerSelection - 1\n        if state.headerSelection < 0 then\n            state.headerSelection = 9\n        end\n        return\n    end\n\n    -- Delete mode: up deletes current step\n    if state.editMode == \"delete\" then\n        if state.sequence[state.currentStep] then\n            state.sequence[state.currentStep] = nil\n            -- Recalculate sequence length from the end\n            local newLength = 0\n            for i = core.maxSteps, 1, -1 do\n                if state.sequence[i] then\n                    newLength = i\n                    break\n                end\n            end\n            state.sequenceLength = newLength\n            state.sequenceLengths[state.activeSequenceIndex] = newLength\n            markSequenceDirty()\n        end\n        return\n    end\n\n    if state.syllableDropdownOpen then\n        moveSyllableDropdownSelection(-1)\n        return\n    end\n\n    -- Up cycles through notes constrained by the selected solfege scale.\n    if not stepCurrentStepNote(1) then\n        _scaleOps.stepSelected(1)\n        if not state.isPlaying then\n            playSelectedNotePreview()\n        end\n    end\n    end\n\n    -- DOWN button handler\n    callbacks.onDown = function()\n    if state.cmdChatInputActive then return end  -- handled by onRawKey\n    if state.solfegeInputActive then return end  -- onRawKey handles up/down when text editor is active\n    if showWelcomeScreen then\n        local maxFirst = math.max(1, #welcomeRecentFiles - (state._welcomeVisibleCount or 5) + 1)\n        state._welcomeFirstVisible = math.min(maxFirst, (state._welcomeFirstVisible or 1) + 1)\n        return\n    end\n    if state.bpmFocused then\n        setTempo(state.tempo - 5)\n        savePreferences()\n        return\n    end\n    if state.keyFocused then\n        adjustRootNote(-1)\n        savePreferences()\n        return\n    end\n    -- Handle mode select screen navigation\n    if state.showingModeSelect then\n        local modes = getModeList()\n        state.selectedModeOption = state.selectedModeOption + 2\n        if state.selectedModeOption > #modes then\n            state.selectedModeOption = state.selectedModeOption - #modes\n        end\n        return\n    end\n\n    -- Handle template browser navigation\n    if state.showingTemplateBrowser then\n        local templates = getTemplateList()\n        state.selectedTemplateIndex = state.selectedTemplateIndex + 1\n        if state.selectedTemplateIndex > #templates then\n            state.selectedTemplateIndex = 1\n        end\n        return\n    end\n\n    if state.showingStepSelect then\n        local items = getStepMenuItems(state.stepSelectSequenceIndex)\n        if #items > 0 then\n            state.selectedStepOption = state.selectedStepOption + 1\n            if state.selectedStepOption > #items then\n                state.selectedStepOption = 1\n            end\n        end\n        return\n    end\n\n    if state.showingSequenceSelect then\n        local items = getSequenceMenuItems()\n        state.selectedSequenceOption = state.selectedSequenceOption + 1\n        if state.selectedSequenceOption > #items then\n            state.selectedSequenceOption = 1\n        end\n        return\n    end\n\n    -- Handle MIDI In device picker navigation\n    if state.showingMidiInPicker then\n        state.midiInPickerSelection = state.midiInPickerSelection + 1\n        if state.midiInPickerSelection > #state.midiInPickerDevices then\n            state.midiInPickerSelection = 1\n        end\n        return\n    end\n\n    -- Handle Mic Input device picker navigation\n    if state.showingMicInputPicker then\n        state.micInputPickerSelection = state.micInputPickerSelection + 1\n        if state.micInputPickerSelection > #state.micInputPickerDevices then\n            state.micInputPickerSelection = 1\n        end\n        return\n    end\n\n    -- Handle Gamepad device picker navigation\n    if state.showingGamepadPicker then\n        state.gamepadPickerSelection = state.gamepadPickerSelection + 1\n        if state.gamepadPickerSelection > #state.gamepadPickerDevices then\n            state.gamepadPickerSelection = 1\n        end\n        return\n    end\n\n    -- Handle MIDI Controls screen navigation\n    if state.showingMidiControls then\n        if not state.midiLearnMode then\n            state.midiControlsSelection = state.midiControlsSelection + 1\n            if state.midiControlsSelection > #MIDI_ACTIONS then\n                state.midiControlsSelection = 1\n            end\n        end\n        return\n    end\n\n    if state.headerSelectionMode then\n        state.headerSelection = state.headerSelection + 1\n        if state.headerSelection > 9 then\n            state.headerSelection = 0\n        end\n        return\n    end\n\n    -- Delete mode: down clears entire sequence\n    if state.editMode == \"delete\" then\n        for i = 1, core.maxSteps do\n            state.sequence[i] = nil\n        end\n        state.sequenceLength = 0\n        state.sequenceLengths[state.activeSequenceIndex] = 0\n        state.currentStep = 1\n        markSequenceDirty()\n        return\n    end\n\n    if state.syllableDropdownOpen then\n        moveSyllableDropdownSelection(1)\n        return\n    end\n\n    -- Down cycles through notes constrained by the selected solfege scale.\n    if not stepCurrentStepNote(-1) then\n        _scaleOps.stepSelected(-1)\n        if not state.isPlaying then\n            playSelectedNotePreview()\n        end\n    end\n    end\n\n    callbacks.onGamepadShortenStep = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen or showWelcomeScreen or state.headerSelectionMode then\n        return\n    end\n    if state.editMode == \"delete\" or state.syllableDropdownOpen then\n        return\n    end\n    tryStretchCurrentStep(-0.5)\n    end\n\n    callbacks.onGamepadExtendStep = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen or showWelcomeScreen or state.headerSelectionMode then\n        return\n    end\n    if state.editMode == \"delete\" or state.syllableDropdownOpen then\n        return\n    end\n    tryStretchCurrentStep(0.5)\n    end\n\n    callbacks.onGamepadInsertStepBefore = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen or showWelcomeScreen or state.headerSelectionMode then\n        return\n    end\n    if state.editMode == \"delete\" or state.syllableDropdownOpen then\n        return\n    end\n\n    local seqLen = state.sequenceLength or 0\n    if seqLen == 0 then\n        tryInsertStepAt(1)\n        return\n    end\n\n    local currentStep = state.currentStep or 1\n    if currentStep < 1 then\n        currentStep = 1\n    elseif currentStep > seqLen then\n        currentStep = seqLen\n    end\n\n    tryInsertStepAt(currentStep)\n    end\n\n    callbacks.onGamepadInsertStep = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen or showWelcomeScreen or state.headerSelectionMode then\n        return\n    end\n    if state.editMode == \"delete\" or state.syllableDropdownOpen then\n        return\n    end\n\n    local seqLen = state.sequenceLength or 0\n    if seqLen == 0 then\n        tryInsertStepAt(1)\n        return\n    end\n\n    local currentStep = state.currentStep or 1\n    if currentStep < 1 then\n        currentStep = 1\n    elseif currentStep > seqLen then\n        currentStep = seqLen\n    end\n\n    tryInsertStepAt(currentStep + 1)\n    end\n\n    callbacks.onGamepadRestStep = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen or showWelcomeScreen or state.headerSelectionMode then\n        return\n    end\n    local seqIndex = state.activeSequenceIndex\n    local seqLen = state.sequenceLength or 0\n    if state.currentStep == seqLen + 1 and seqLen < core.maxSteps then\n        state.sequenceLength = seqLen + 1\n        state.sequenceLengths[seqIndex] = state.sequenceLength\n        state.sequence[state.currentStep] = { note = 13, octave = 4 }\n        state.selectedNote = 13\n        state.currentOctave = 4\n        markSequenceDirty()\n        return\n    end\n    if state.currentStep >= 1 and state.currentStep <= seqLen then\n        if core.setStepRest(state, seqIndex, state.currentStep) then\n            state.selectedNote = 13\n            state.currentOctave = 4\n            markSequenceDirty()\n        end\n    end\n    end\n\n    callbacks.onGamepadPrimary = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen then\n        if callbacks.onPrimary then\n            callbacks.onPrimary()\n        end\n        return\n    end\n    if state.syllableDropdownOpen then\n        applySyllableDropdownChoice()\n        return\n    end\n    if state.currentStep > (state.sequenceLength or 0) and not state.isPlaying then\n        if callbacks.onPrimary then\n            callbacks.onPrimary()\n        end\n        return\n    end\n    -- Gamepad A = play/resume\n    if not state.isPlaying then\n        startPlayback()\n    end\n    end\n\n    callbacks.onGamepadPause = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen then\n        if callbacks.onSecondary then\n            callbacks.onSecondary()\n        end\n        return\n    end\n    -- B button = play/pause toggle (pause if playing, start if stopped)\n    if state.isPlaying then\n        pausePlayback()\n    else\n        startPlayback()\n    end\n    end\n\n    callbacks.onGamepadMenu = function()\n    if state.isPlaying then\n        pausePlayback()\n    end\n    welcomeRecentFiles = findRecentMusicXMLFiles(50)\n    showWelcomeScreen = true\n    state._welcomeFirstVisible = 1\n    end\n\n    callbacks.onToggleFullscreen = function()\n        local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n        if host and host.toggleFullscreen then\n            host.toggleFullscreen()\n        end\n    end\n\n    -- L3 (left stick click): delete current step\n    callbacks.onGamepadDeleteStep = function()\n    if state.bpmEditing then return end\n    if state.lyricEditingStepIndex then return end\n    if state.musicXMLFilenameEditing then return end\n    if state.lyricNotesPanelOpen then return end\n    if state.showingModeSelect or state.showingTemplateBrowser or state.showingStepSelect\n       or state.showingSequenceSelect or state.showingMidiInPicker\n       or state.showingMicInputPicker or state.showingGamepadPicker or state.showingMidiControls\n       or showRecordingScreen or showWelcomeScreen then\n        return\n    end\n    tryDeleteCurrentStep()\n    end\n\n    -- PRIMARY button (A button) handler\n    callbacks.onPrimary = function()\n    if state.cmdChatInputActive then return end  -- handled by onRawKey\n    if state.bpmEditing then return end  -- handled by onRawKey\n    if state.lyricEditingStepIndex then return end  -- handled by onRawKey\n    if state.solfegeInputActive then return end  -- handled by onRawKey\n    if state.syllableDropdownOpen then\n        applySyllableDropdownChoice()\n        return\n    end\n    -- Handle mode select screen\n    if state.showingModeSelect then\n        local modes = getModeList()\n        local selectedMode = modes[state.selectedModeOption]\n        if selectedMode then\n            if selectedMode.label == \"Sing Solfege\" then\n                state.singSolfegeMode = true\n                resetSingSolfegeOctaveOffset()\n                syncPitchRecognitionForSingSolfege()\n                savePreferences()\n                closeModeSelectScreen()\n                return\n            elseif selectedMode.label == \"Patterns\" then\n                closeModeSelectScreen()\n                showSequenceSelectScreen()\n                return\n            elseif selectedMode.label == \"Import MIDI\" then\n                closeModeSelectScreen()\n                local path = pickFile(\"Select a MIDI file\", \"mid,midi\")\n                if path then importMidi(path) end\n                return\n            elseif selectedMode.label == \"Import MusicXML\" then\n                closeModeSelectScreen()\n                local path = pickFile(\"Select a MusicXML file\", \"musicxml,xml\")\n                if path then importMusicXML(path) end\n                return\n            elseif selectedMode.label == \"Import DOCX Lyrics\" then\n                closeModeSelectScreen()\n                local path = pickFile(\"Select a DOCX lyrics file\", \"docx\")\n                if path then\n                    local ok = importDocxLyrics(path)\n                    if ok then state.linkedLyricsDocxPath = path end\n                end\n                return\n            elseif selectedMode.label == \"Export MusicXML\" then\n                closeModeSelectScreen()\n                exportMusicXML(resolveMusicXMLStoragePath(\"export.musicxml\"))\n                return\n            elseif selectedMode.label == \"Export DOCX Lyrics\" then\n                closeModeSelectScreen()\n                exportDocxLyrics(\"export.docx\")\n                return\n            elseif selectedMode.label == \"Export MusicXML As\" then\n                closeModeSelectScreen()\n                exportMusicXML(buildMusicXMLSaveAsFilename())\n                return\n            elseif selectedMode.label == \"Steps\" then\n                closeModeSelectScreen()\n                showStepSelectScreen()\n                return\n            elseif selectedMode.label == \"MIDI Out\" then\n                state.midiOutEnabled = not state.midiOutEnabled\n                if not state.midiOutEnabled and midiOut then\n                    midiOut.allNotesOff()\n                end\n                savePreferences()\n                return\n            elseif selectedMode.label == \"MIDI In\" then\n                closeModeSelectScreen()\n                showMidiInDevicePicker()\n                return\n            elseif selectedMode.label == \"MIDI Controls\" then\n                closeModeSelectScreen()\n                showMidiControlsScreen()\n                return\n            else\n                state.singSolfegeMode = false\n            end\n            syncPitchRecognitionForSingSolfege()\n            savePreferences()\n        end\n        closeModeSelectScreen()\n        return\n    end\n\n    -- Handle template browser\n    if state.showingTemplateBrowser then\n        loadSelectedTemplate()\n        return\n    end\n\n    if state.showingStepSelect then\n        local seqIndex = state.stepSelectSequenceIndex\n        local items = getStepMenuItems(seqIndex)\n        local selectedItem = items[state.selectedStepOption]\n        if selectedItem and selectedItem.isEditAction then\n            -- Switch to this sequence and enter edit mode\n            setActiveSequence(seqIndex)\n            state.singSolfegeMode = false\n            state.micStepRecording = false\n            syncPitchRecognitionForSingSolfege()\n            savePreferences()\n            closeStepSelectScreen()\n            markSequenceDirty()\n        elseif selectedItem and selectedItem.isSingAction then\n            -- Switch to this sequence and enter sing solfege mode\n            setActiveSequence(seqIndex)\n            state.singSolfegeMode = true\n            resetSingSolfegeOctaveOffset()\n            syncPitchRecognitionForSingSolfege()\n            savePreferences()\n            closeStepSelectScreen()\n            startPlayback()\n            markSequenceDirty()\n        elseif selectedItem and selectedItem.isSequenceMuteToggle then\n            state.sequenceMutes[seqIndex] = not state.sequenceMutes[seqIndex]\n            markSequenceDirty()\n            if state.isPlaying then\n                stopPlayback()\n                startPlayback()\n            end\n        elseif selectedItem and selectedItem.isDeleteSequence then\n            -- Count how many sequences exist\n            local sequenceCount = 0\n            for i = 1, core.maxSequences do\n                if state.sequences[i] then\n                    sequenceCount = sequenceCount + 1\n                end\n            end\n            -- Only allow delete if more than one sequence exists\n            if sequenceCount > 1 then\n                recordStepHistoryIfNeeded()\n                core.deleteSequence(state, seqIndex)\n                markSequenceDirty()\n                closeStepSelectScreen()\n                showSequenceSelectScreen()\n            end\n        end\n        return\n    end\n\n    if state.showingSequenceSelect then\n        local items = getSequenceMenuItems()\n        local selectedItem = items[state.selectedSequenceOption]\n        if selectedItem and selectedItem.sequenceIndex then\n            -- Set this sequence as active\n            local seqIndex = selectedItem.sequenceIndex\n            setActiveSequence(seqIndex)\n            markSequenceDirty()\n            -- Open step select screen for this sequence\n            local length = state.sequenceLengths[seqIndex] or 0\n            if length > 0 then\n                closeSequenceSelectScreen()\n                showStepSelectScreen(seqIndex)\n            else\n                -- No steps to edit, just switch to this sequence\n                if selectedItem.enabled and selectedItem.action then\n                    selectedItem.action()\n                    markSequenceDirty()\n                end\n            end\n        elseif selectedItem and selectedItem.enabled and selectedItem.action then\n            -- \"Add New Sequence\" item\n            selectedItem.action()\n            markSequenceDirty()\n        end\n        return\n    end\n\n    -- Welcome screen consumes primary button\n    if showWelcomeScreen then return end\n\n    -- Handle recording screen\n    if showRecordingScreen then\n        if not isRecording then\n            startRecording()\n        end\n        return\n    end\n\n    -- Handle MIDI In device picker: select device and close\n    if state.showingMidiInPicker then\n        local dev = state.midiInPickerDevices[state.midiInPickerSelection]\n        if dev then\n            connectMidiInDevice(dev)\n        end\n        closeMidiInDevicePicker()\n        showModeSelectScreen()\n        return\n    end\n\n    -- Handle Mic Input device picker: select device and close\n    if state.showingMicInputPicker then\n        local dev = state.micInputPickerDevices[state.micInputPickerSelection]\n        if dev then\n            connectMicInputDevice(dev)\n        end\n        closeMicInputDevicePicker()\n        showModeSelectScreen()\n        return\n    end\n\n    -- Handle Gamepad device picker: select device and close\n    if state.showingGamepadPicker then\n        local dev = state.gamepadPickerDevices[state.gamepadPickerSelection]\n        if dev then\n            connectGamepadDevice(dev)\n        end\n        closeGamepadDevicePicker()\n        showModeSelectScreen()\n        return\n    end\n\n    -- Handle MIDI Controls screen: A = start learn for selected action\n    if state.showingMidiControls then\n        local act = MIDI_ACTIONS[state.midiControlsSelection]\n        if act then\n            state.midiLearnMode = true\n            state.midiLearnTarget = act.id\n        end\n        return\n    end\n\n    -- Handle header selection actions (not on Mac desktop — spacebar is play/pause there)\n    if not isRunningOnMacDesktop() and (state.isPlaying or state.headerSelectionMode) then\n        if state.headerSelection == 0 then\n            state.sidebarOpen = not state.sidebarOpen\n            savePreferences()\n            return\n        elseif state.headerSelection == 1 then\n            showModeSelectScreen()\n            return\n        elseif state.headerSelection == 2 then\n            showSequenceSelectScreen()\n            return\n        elseif state.headerSelection == 7 then\n            if undoStepChange() then\n                return\n            end\n        elseif state.headerSelection == 8 then\n            if redoStepChange() then\n                return\n            end\n        end\n    end\n\n    -- Enter on the + button position (one past sequenceLength) adds a rest step\n    if state.currentStep > (state.sequenceLength or 0) then\n        local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n        local baseLen = (grid and grid.displaySequenceLength) or (state.sequenceLength or 0)\n        if baseLen < core.maxSteps then\n            for i = baseLen + 1, (state.sequenceLength or 0) do\n                state.sequence[i] = nil\n            end\n            local newLen = baseLen + 1\n            state.sequenceLength = newLen\n            state.sequenceLengths[state.activeSequenceIndex] = newLen\n            state.sequence[newLen] = { note = 13, octave = 4 }\n            markSequenceDirty()\n            state.currentStep = newLen\n            state.selectedNote = 13\n            state.currentOctave = 4\n        end\n        return\n    end\n\n    if state.isPlaying then\n        pausePlayback()\n    else\n        startPlayback()\n    end\n    end\n\n    -- SECONDARY button (B button) handler\n    callbacks.onSecondary = function()\n    if state.cmdChatInputActive then return end  -- handled by onRawKey\n    if state.bpmEditing then return end  -- handled by onRawKey\n    if state.lyricEditingStepIndex then return end  -- handled by onRawKey\n    if state.musicXMLFilenameEditing then return end  -- handled by onRawKey\n    if state.lyricNotesPanelOpen then return end  -- handled by onRawKey\n    if state.solfegeInputActive then return end  -- handled by onRawKey\n    -- Cancel quick MIDI Learn (m-key shortcut) if active outside MIDI Controls screen\n    if state.midiLearnMode and not state.showingMidiControls then\n        state.midiLearnMode = false\n        state.midiLearnTarget = nil\n        return\n    end\n\n    -- Delete the currently selected step\n    if tryDeleteCurrentStep() then\n        return\n    end\n    if not state.isPlaying and state.currentStep and not state.sequence[state.currentStep] then\n        return\n    end\n\n    -- Menu screens take priority - B always closes them\n    if showWelcomeScreen then return end\n    if showRecordingScreen then\n        closeRecordingScreen()\n        return\n    end\n\n    if state.showingTemplateBrowser then\n        closeTemplateBrowser()\n        return\n    end\n\n    if state.showingStepSelect then\n        closeStepSelectScreen()\n        showSequenceSelectScreen()\n        return\n    end\n\n    if state.showingSequenceSelect then\n        closeSequenceSelectScreen()\n        showModeSelectScreen()\n        return\n    end\n\n    if state.showingMidiInPicker then\n        closeMidiInDevicePicker()\n        showModeSelectScreen()\n        return\n    end\n\n    if state.showingMicInputPicker then\n        closeMicInputDevicePicker()\n        showModeSelectScreen()\n        return\n    end\n\n    if state.showingGamepadPicker then\n        closeGamepadDevicePicker()\n        showModeSelectScreen()\n        return\n    end\n\n    if state.showingMidiControls then\n        if state.midiLearnMode then\n            -- Cancel learn mode\n            state.midiLearnMode = false\n            state.midiLearnTarget = nil\n        else\n            closeMidiControlsScreen()\n            showModeSelectScreen()\n        end\n        return\n    end\n\n    if state.showingModeSelect then\n        closeModeSelectScreen()\n        return\n    end\n\n    -- In sing solfege mode, B goes back to mode select\n    if state.singSolfegeMode then\n        if state.isPlaying then\n            stopPlayback()\n        end\n        showModeSelectScreen()\n        return\n    end\n\n    -- B opens mode selection screen (Playdate only — on Mac desktop, Delete/Backspace\n    -- should not open the menu when delete has nothing to act on)\n    if not state.isPlaying and not isRunningOnMacDesktop() then\n        showModeSelectScreen()\n        return\n    end\n    end\n\n    -- MOUSE click handler (SDL desktop only)\n    -- Helper: returns step index (1-based) for a pixel coordinate, or nil if outside grid.\n    -- Accounts for stretched steps: clicking anywhere on a stretched cell returns the owner step.\n    local function stepIndexAtPoint(px, py)\n        if ((state.solfegeTextMode or \"both\") == \"lyrics\") or (state.hideSteps and not state.useShapeNotes) then\n            return nil\n        end\n        local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n        if not grid then return nil end\n        local spanX = grid.stepWidth + grid.horizontalGap\n        local spanY = grid.stepHeight + grid.verticalGap\n        local row = math.floor((py - grid.startY) / spanY)\n        if row < 0 or row >= grid.rowsToShow then return nil end\n        local cellY = grid.startY + row * spanY\n        if py < cellY or py >= cellY + grid.stepHeight then return nil end\n\n        -- Scan columns in this row using xOffsets to find which step px falls in\n        local xOffsets = grid.xOffsets or {}\n        local seqLen = grid.displaySequenceLength or 0\n        local scrollCol = grid.gridScrollCol or 0\n        for col = 0, grid.stepsPerRow - 1 do\n            local idx = row * grid.stepsPerRow + col + 1\n            if idx > seqLen then break end\n            local xOff = xOffsets[idx] or 0\n            local stepX = grid.startX + (col - scrollCol) * spanX - xOff\n            local step = state.sequence[idx]\n            local stepLen = step and (step.length or 1) or 1\n            -- For nil (continuation) slots, stepLen from the nil itself is 1 but\n            -- they're covered by a prior stretched owner — skip them as direct hits.\n            if not step then\n                -- continuation slot: find owner\n                local emptyCellHit = px >= stepX and px < stepX + grid.stepWidth\n                for lookback = 1, col do\n                    local ownerIdx = idx - lookback\n                    if ownerIdx < 1 then break end\n                    local ownerStep = state.sequence[ownerIdx]\n                    if ownerStep then\n                        local ownerCol = (ownerIdx - 1) % grid.stepsPerRow\n                        local ownerRow = math.floor((ownerIdx - 1) / grid.stepsPerRow)\n                        if ownerRow == row and core.getStepLength(ownerStep) > lookback then\n                            local ownerXOff = xOffsets[ownerIdx] or 0\n                            local ownerX = grid.startX + (ownerCol - scrollCol) * spanX - ownerXOff\n                            local ownerLen = core.getStepLength(ownerStep)\n                            local clampedLen = math.min(math.ceil(ownerLen), grid.stepsPerRow - ownerCol)\n                            local ownerW = grid.stepWidth * clampedLen + grid.horizontalGap * (clampedLen - 1)\n                            if px >= ownerX and px < ownerX + ownerW then\n                                return ownerIdx, ownerCol, ownerRow\n                            end\n                        end\n                        break\n                    end\n                end\n                if emptyCellHit then\n                    return idx, col, row\n                end\n            else\n                local stepW\n                if stepLen < 1.0 then\n                    stepW = math.max(4, math.floor(grid.stepWidth * stepLen))\n                else\n                    local clampedLen = math.min(math.ceil(stepLen), grid.stepsPerRow - col)\n                    stepW = grid.stepWidth * clampedLen + grid.horizontalGap * (clampedLen - 1)\n                end\n                if px >= stepX and px < stepX + stepW then\n                    return idx, col, row\n                end\n            end\n        end\n        return nil\n    end\n\n    -- Modifier key state (tracked across key-down / key-up events)\n    local _shiftHeld = false\n    local _cmdHeld   = false  -- Cmd on macOS (Left/Right GUI), Ctrl on other platforms\n    local _altHeld   = false  -- Option/Alt key\n\n    -- Multi-click detection for word/line selection\n    local _solfegeLastClickTime = 0\n    local _solfegeLastClickX    = -999\n    local _solfegeLastClickY    = -999\n    local _solfegeClickCount    = 0\n    local DOUBLE_CLICK_MS       = 400\n    local DOUBLE_CLICK_RADIUS   = 8\n\n    callbacks.onMouseDown = function(x, y, button, sourceWindow)\n    -- Options window click: insert text into main app\n    if sourceWindow == \"options\" then\n        state._optWinMouseX = x; state._optWinMouseY = y\n        local btns = state._optWinBtns\n        if btns then\n            for _, btn in ipairs(btns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    insertSolfegeTemplateText(btn.text)\n                    return\n                end\n            end\n        end\n        return\n    end\n    -- Shortcut help overlay: dismiss on click outside the panel\n    if state.showShortcutHelp then\n        local b = state._shortcutHelpBounds\n        if not b or x < b.x or x >= b.x + b.w or y < b.y or y >= b.y + b.h then\n            state.showShortcutHelp = false\n            return\n        end\n    end\n\n    -- Command chat panel: click handling\n    if state.cmdChatOpen then\n        -- Solfege syllable buttons: play immediately, stop on mouseUp\n        if state._solfegeSyllableBtns then\n            for _, btn in ipairs(state._solfegeSyllableBtns) do\n                if x >= btn.x and x < btn.x + btn.w and y >= btn.y and y < btn.y + btn.h then\n                    if state._playbackEndingGrace and not state.isPlaying then\n                        state.isPlaying = true\n                        state._playbackEndingGrace = false\n                        playbackClock.nextStepAt = getCurrentTimeMilliseconds()\n                    end\n                    local n, o = transposeNoteForKey(btn.noteIndex, state.currentOctave or 4)\n                    playNote(n, o, nil, primaryVoice, nil, 100, 1.0)\n                    state._syllableBtnHeld = {noteIndex = btn.noteIndex, label = btn.label, t = os.clock()}\n                    return\n                end\n            end\n        end\n        local mb = state._cmdChatMuteBtn\n        if mb and x >= mb.x and y >= mb.y and x < mb.x + mb.w and y < mb.y + mb.h then\n            setCmdChatMuted(not state.cmdChatMuted)\n            return\n        end\n        local st = state._cmdChatScrollTrack\n        if st and x >= st.x and y >= st.y and x < st.x + st.w and y < st.y + st.h then\n            local maxChatScroll = state._cmdChatScrollMax or 0\n            if maxChatScroll > 0 then\n                local rel = (y - st.y) / math.max(1, st.h)\n                state.cmdChatScrollOffset = math.max(0, math.min(maxChatScroll, math.floor(maxChatScroll - (rel * maxChatScroll) + 0.5)))\n            end\n            return\n        end\n        local ib = state._cmdChatInputBounds\n        if ib and x >= ib.x and y >= ib.y and x < ib.x + ib.w and y < ib.y + ib.h then\n            local pos = cmdChatTextPosFromPoint(x, y)\n            state.cmdChatInputActive = true\n            state.cmdChatInputCursor = pos\n            if _shiftHeld and state.cmdChatSelAnchor ~= nil then\n                state.cmdChatSelFocus = pos\n            else\n                state.cmdChatSelAnchor = pos\n                state.cmdChatSelFocus = pos\n            end\n            state._cmdChatDragSel = true\n            state.cmdChatCursorResetTime = os.clock()\n            return\n        end\n        local rh = state._cmdChatResizeHandle\n        if rh and x >= rh.x and y >= rh.y and x < rh.x + rh.w and y < rh.y + rh.h then\n            state._cmdChatDraggingResize = true\n            state._cmdChatDragStartY = y\n            state._cmdChatDragStartH = state.cmdChatBottomH or 60\n            return\n        end\n        local pb = state._cmdChatPanelBounds\n        if pb and x >= pb.x and y >= pb.y and x < pb.x + pb.w and y < pb.y + pb.h then\n            state.cmdChatInputActive = true\n            state.cmdChatCursorResetTime = os.clock()\n            return\n        end\n    end\n\n    -- Scale steps input dialog: close on click outside\n    if state.scaleStepsInputOpen then\n        local dlg = state._scaleStepsDlgBounds\n        if not dlg or x < dlg.x or x >= dlg.x + dlg.w or y < dlg.y or y >= dlg.y + dlg.h then\n            state.scaleStepsInputOpen = false\n            state.scaleStepsInputBuffer = nil\n            state.scaleStepsInputError = nil\n        end\n        return\n    end\n\n    -- Context menu: hit-test items or dismiss on any click.\n    do\n        local ctx = state._solfegeCtxMenu\n        if ctx then\n            state._solfegeCtxMenu = nil\n            for _, item in ipairs(ctx.items) do\n                local b = item.bounds\n                if b and x >= b.x and y >= b.y and x < b.x + b.w and y < b.y + b.h then\n                    if item.enabled then\n                        local ctxType = ctx.context or \"solfege\"\n                        if ctxType == \"solfege\" then\n                            if item.action == \"undo\"  then undoSolfegeTextEdit() end\n                            if item.action == \"redo\"  then redoSolfegeTextEdit() end\n                            if item.action == \"copy\"  then copySolfegeSelection() end\n                            if item.action == \"cut\"   then cutSolfegeSelection() end\n                            if item.action == \"paste\" then pasteIntoSolfegeText() end\n                        elseif ctxType == \"lyric_notes\" then\n                            if item.action == \"copy\" then\n                                copyLyricNotesSelection()\n                            elseif item.action == \"cut\" then\n                                cutLyricNotesSelection()\n                            elseif item.action == \"paste\" then\n                                pasteIntoLyricNotesText()\n                            end\n                        elseif ctxType == \"lyric_step\" then\n                            if item.action == \"copy\" then\n                                copyLyricStepSelection()\n                            elseif item.action == \"cut\" then\n                                cutLyricStepSelection()\n                            elseif item.action == \"paste\" then\n                                pasteIntoLyricStepText()\n                            end\n                        end\n                    end\n                    return  -- consumed by menu\n                end\n            end\n            -- Click outside menu: dismissed; fall through to normal handling\n        end\n    end\n\n    -- Text-window clicks are routed here with secondary-window coords.\n    -- Handle only text-input elements; skip all main-window logic.\n    if sourceWindow == \"text\" then\n        if state.lyricNotesDetached and state.lyricNotesPanelOpen then\n            if handleLyricNotesPanelMouseDown(x, y, button, gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240) then\n                return\n            end\n        end\n        local dh = state._solfegeDragHandle\n        if dh and x >= dh.x and y >= dh.y and x < dh.x + dh.w and y < dh.y + dh.h then\n            solfegeDrag.active = true; solfegeDrag.startX = x\n            solfegeDrag.startWidth = state.solfegeInputWidth or 130; solfegeDrag.side = dh.side\n            return\n        end\n        local mbtns = state._solfegeModeBtns\n        if mbtns then\n            for _, mb in ipairs(mbtns) do\n                if x >= mb.x and y >= mb.y and x < mb.x + mb.w and y < mb.y + mb.h then\n                    -- Save buffer before commit so we can restore if re-serialization empties it.\n                    state._modeSwOldBuf = state.solfegeInputBuffer or \"\"\n                    if state.solfegeInputActive then commitSolfegeInput() end\n                    state.solfegeTextMode = mb.mode\n                    state.solfegeInputBuffer = serializeSequenceToText()\n                    state._solfegeSeqText = state.solfegeInputBuffer\n                    -- If the new serialization is empty but the user had typed content, restore it.\n                    if state.solfegeInputBuffer == \"\" and state._modeSwOldBuf ~= \"\" then\n                        state.solfegeInputBuffer = state._modeSwOldBuf\n                        state._solfegeSeqText = state.solfegeInputBuffer\n                    end\n                    state._modeSwOldBuf = nil\n                    state.solfegeInputActive = false\n                    savePreferences(); return\n                end\n            end\n        end\n        -- LN button: toggle lyric notes panel\n        local lnBtn = state._solfegelyricNotesPanelBtn\n        if lnBtn and x >= lnBtn.x and y >= lnBtn.y and x < lnBtn.x + lnBtn.w and y < lnBtn.y + lnBtn.h then\n            state.lyricNotesPanelOpen = not state.lyricNotesPanelOpen\n            if state.lyricNotesPanelOpen then\n                state.lyricNotesBuffer = state.lyricNotesBuffer or \"\"\n                state.lyricEditingStepIndex = nil\n                state.lyricInputBuffer = nil\n                state.lyricNotesInputActive = false\n            end\n            markSequenceDirty(); return\n        end\n        local lyricsWinBtn = state._solfegeLyricsWindowBtn\n        if lyricsWinBtn and x >= lyricsWinBtn.x and y >= lyricsWinBtn.y and x < lyricsWinBtn.x + lyricsWinBtn.w and y < lyricsWinBtn.y + lyricsWinBtn.h then\n            if state.solfegeTextInputSide == \"window\" then\n                state.solfegeTextInputSide = \"bottom\"\n                local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n                if host and host.textWindow and host.textWindow.isOpen() then\n                    host.textWindow.close()\n                end\n                savePreferences()\n            else\n                openLyricsWindow()\n            end\n            return\n        end\n        local mainWinBtn = state._solfegeMainWindowBtn\n        if mainWinBtn and x >= mainWinBtn.x and y >= mainWinBtn.y and x < mainWinBtn.x + mainWinBtn.w and y < mainWinBtn.y + mainWinBtn.h then\n            state.solfegeTextInputSide = \"bottom\"\n            state._textWindowWasOpen = false\n            local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n            if host and host.textWindow and host.textWindow.isOpen() then\n                host.textWindow.close()\n            end\n            savePreferences()\n            return\n        end\n        local smi = state._solfegeSizeMenuItems\n        if smi then\n            for _, item in ipairs(smi) do\n                if x >= item.x and y >= item.y and x < item.x + item.w and y < item.y + item.h then\n                    state.solfegeTextFontSize = item.value\n                    state._solfegeSizeMenuOpen = false\n                    savePreferences(); return\n                end\n            end\n        end\n        local sbtn = state._solfegeSizeBtn\n        if sbtn and x >= sbtn.x and y >= sbtn.y and x < sbtn.x + sbtn.w and y < sbtn.y + sbtn.h then\n            state._solfegeSizeMenuOpen = not state._solfegeSizeMenuOpen\n            return\n        else\n            state._solfegeSizeMenuOpen = false\n        end\n        local pb = state._solfegeParagraphBtn\n        if pb and x >= pb.x and y >= pb.y and x < pb.x + pb.w and y < pb.y + pb.h then\n            if not state.solfegeInputBuffer or state.solfegeInputBuffer == \"\" then\n                state.solfegeInputBuffer = state._solfegeSeqText or \"\"\n            end\n            _pushSolfegeTextUndoState(true)\n            local buf = state.solfegeInputBuffer or \"\"\n            local cur = state.solfegeInputCursor\n            local marker = \"\\n\\n\"\n            if cur then\n                local before = buf:sub(1, cur - 1):gsub(\"%s+$\", \"\")\n                local after = buf:sub(cur):gsub(\"^%s+\", \"\")\n                state.solfegeInputBuffer = before .. marker .. after\n                state.solfegeInputCursor = #before + #marker + 1\n            else\n                state.solfegeInputBuffer = buf:gsub(\"%s+$\", \"\") .. marker\n            end\n            state.solfegeInputActive = true\n            state._solfegeLastCursorActivity = os.clock()\n            _requestLiveApply(true); return\n        end\n        local selBar = state._solfegeSelBar\n        if selBar and selBar.btns then\n            for _, btn in ipairs(selBar.btns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    if btn.action == \"copy\" then copySolfegeSelection()\n                    elseif btn.action == \"cut\" then cutSolfegeSelection()\n                    elseif btn.action == \"paste\" then pasteIntoSolfegeText()\n                    end\n                    state.solfegeInputActive = true\n                    return\n                end\n            end\n        end\n        local cb = state._solfegeCopyBtn\n        if cb and x >= cb.x and y >= cb.y and x < cb.x + cb.w and y < cb.y + cb.h then\n            copySolfegeSelection(); return\n        end\n        local xb = state._solfegeCutBtn\n        if xb and x >= xb.x and y >= xb.y and x < xb.x + xb.w and y < xb.y + xb.h then\n            cutSolfegeSelection(); return\n        end\n        local vb = state._solfegePasteBtn\n        if vb and x >= vb.x and y >= vb.y and x < vb.x + vb.w and y < vb.y + vb.h then\n            pasteIntoSolfegeText(); return\n        end\n        local bt = state._solfegeBreakToggleBtn\n        if bt and x >= bt.x and y >= bt.y and x < bt.x + bt.w and y < bt.y + bt.h then\n            state.solfegeShowBreaks = not (state.solfegeShowBreaks ~= false)\n            savePreferences(); return\n        end\n        local sp = state._solfegeSpellBtn\n        if sp and x >= sp.x and y >= sp.y and x < sp.x + sp.w and y < sp.y + sp.h then\n            state.solfegeSpellCheck = not (state.solfegeSpellCheck == true)\n            if state.solfegeSpellCheck then _spellCheckPending = true else state._solfegeSpellErrors = nil end\n            savePreferences(); return\n        end\n        local tmb = state._solfegeTemplateBtn\n        if tmb and x >= tmb.x and y >= tmb.y and x < tmb.x + tmb.w and y < tmb.y + tmb.h then\n            local opening = not (state._solfegeTemplateMenuOpen == true)\n            state._solfegeTemplateMenuOpen = opening\n            if opening then\n                state._solfegeTemplateSearch = \"\"\n                state._solfegeTemplateCollapsed = {}\n            else\n                state._solfegeTemplateSaving = nil\n                state._solfegeTemplateSaveName = nil\n                state._solfegeTemplateRenaming = nil\n            end\n            return\n        elseif state._solfegeTemplateMenuOpen then\n            -- Category header collapse toggles (only while menu is open)\n            local tch = state._solfegeTemplateCategoryHeaders\n            if tch then\n                for _, hdr in ipairs(tch) do\n                    if x >= hdr.x and y >= hdr.y and x < hdr.x + hdr.w and y < hdr.y + hdr.h then\n                        if not state._solfegeTemplateCollapsed then state._solfegeTemplateCollapsed = {} end\n                        state._solfegeTemplateCollapsed[hdr.name] = not state._solfegeTemplateCollapsed[hdr.name]\n                        return\n                    end\n                end\n            end\n            local tmi = state._solfegeTemplateMenuItems\n            if tmi then\n                for _, item in ipairs(tmi) do\n                    if x >= item.x and y >= item.y and x < item.x + item.w and y < item.y + item.h then\n                        if item.type == \"search_input\" then\n                            -- no-op, keyboard routes here automatically\n                        elseif item.type == \"mode_toggle\" then\n                            if item.replaceX and x >= item.replaceX then\n                                state._solfegeTemplateReplaceMode = true\n                            else\n                                state._solfegeTemplateReplaceMode = false\n                            end\n                        elseif item.type == \"save\" then\n                            state._solfegeTemplateSaving = true\n                            state._solfegeTemplateSaveName = \"\"\n                        elseif item.type == \"save_input\" then\n                            -- no-op (keyboard handles it)\n                        elseif item.type == \"save_flash\" or item.type == \"save_disabled\" then\n                            -- no-op\n                        elseif item.type == \"user_item\" then\n                            if item.deleteX and x >= item.deleteX then\n                                deleteUserSolfegeTemplate(item.userIndex)\n                                state._solfegeTemplateRenaming = nil\n                                state._solfegeTemplateSaving = nil\n                                state._solfegeTemplateSaveName = nil\n                                state._solfegeTemplateMenuOpen = false\n                            elseif item.renameX and x >= item.renameX then\n                                state._solfegeTemplateRenaming = {index = item.userIndex, name = item.name}\n                            else\n                                state._solfegeTemplateMenuOpen = false\n                                state._solfegeTemplateSaving = nil\n                                state._solfegeTemplateSaveName = nil\n                                state._solfegeTemplateRenaming = nil\n                                insertSolfegeTemplateText(item.text)\n                            end\n                        else\n                            state._solfegeTemplateMenuOpen = false\n                            state._solfegeTemplateSaving = nil\n                            state._solfegeTemplateSaveName = nil\n                            state._solfegeTemplateRenaming = nil\n                            insertSolfegeTemplateText(item.text)\n                        end\n                        return\n                    end\n                end\n            end\n            -- Click outside menu while open — close it\n            state._solfegeTemplateMenuOpen = false\n            state._solfegeTemplateSaving = nil\n            state._solfegeTemplateSaveName = nil\n            state._solfegeTemplateRenaming = nil\n        end\n        -- All-options list button (≣)\n        local aob = state._solfegeAllOptionsBtn\n        if aob and x >= aob.x and y >= aob.y and x < aob.x + aob.w and y < aob.y + aob.h then\n            state._solfegeAllOptionsOpen = not state._solfegeAllOptionsOpen\n            if state._solfegeAllOptionsOpen then state._allOptionsPanelX = nil end\n            return\n        end\n        -- All-options floating window interactions (close btn, drag, item clicks)\n        if state._solfegeAllOptionsOpen then\n            local cb = state._solfegeAllOptionsCloseBtn\n            if cb and x >= cb.x and y >= cb.y and x < cb.x + cb.w and y < cb.y + cb.h then\n                state._solfegeAllOptionsOpen = false; return\n            end\n            local tb = state._solfegeAllOptionsTitleBar\n            if tb and x >= tb.x and y >= tb.y and x < tb.x + tb.w and y < tb.y + tb.h then\n                state._allOptionsDragActive = true\n                state._allOptionsDragOX = x - (state._allOptionsPanelX or 0)\n                state._allOptionsDragOY = y - (state._allOptionsPanelY or 0)\n                return\n            end\n            local allBtns = state._solfegeAllOptionsBtns\n            if allBtns then\n                for _, btn in ipairs(allBtns) do\n                    if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                        insertSolfegeTemplateText(btn.text); return\n                    end\n                end\n            end\n        end\n        local kmb = state._solfegeKeyMinusBtn\n        if kmb and x >= kmb.x and y >= kmb.y and x < kmb.x + kmb.w and y < kmb.y + kmb.h then\n            _scaleOps.setRootOnly(((state.rootNote or 0) - 1 + 12) % 12); return\n        end\n        local kpb = state._solfegeKeyPlusBtn\n        if kpb and x >= kpb.x and y >= kpb.y and x < kpb.x + kpb.w and y < kpb.y + kpb.h then\n            _scaleOps.setRootOnly(((state.rootNote or 0) + 1) % 12); return\n        end\n        local omb = state._solfegeOctMinusBtn\n        if omb and x >= omb.x and y >= omb.y and x < omb.x + omb.w and y < omb.y + omb.h then\n            local cur = state.currentOctave or 4\n            if cur > 1 then shiftAllStepOctaves(-1); state.currentOctave = cur - 1 end\n            _syncKeyLineInTextBuffer(); return\n        end\n        local opb = state._solfegeOctPlusBtn\n        if opb and x >= opb.x and y >= opb.y and x < opb.x + opb.w and y < opb.y + opb.h then\n            local cur = state.currentOctave or 4\n            if cur < 8 then shiftAllStepOctaves(1); state.currentOctave = cur + 1 end\n            _syncKeyLineInTextBuffer(); return\n        end\n        local dbtns = state._solfegeDockBtns\n        if dbtns then\n            for _, db in ipairs(dbtns) do\n                if x >= db.x and y >= db.y and x < db.x + db.w and y < db.y + db.h then\n                    state.solfegeTextInputSide = db.side\n                    if db.side ~= \"bottom\" then state.solfegeTextOnlyMode = false end\n                    if db.side == \"float\" and not state.solfegeFloatX then\n                        state.solfegeFloatX = 40; state.solfegeFloatY = 60\n                        state.solfegeFloatW = 240; state.solfegeFloatH = 180\n                    end\n                    savePreferences(); return\n                end\n            end\n        end\n        -- Solfege syllable button click\n        if state._solfegeSyllableBtns then\n            for _, btn in ipairs(state._solfegeSyllableBtns) do\n                if x >= btn.x and x < btn.x + btn.w and y >= btn.y and y < btn.y + btn.h then\n                    if state._playbackEndingGrace and not state.isPlaying then\n                        state.isPlaying = true\n                        state._playbackEndingGrace = false\n                        playbackClock.nextStepAt = getCurrentTimeMilliseconds()\n                    end\n                    local n, o = transposeNoteForKey(btn.noteIndex, state.currentOctave or 4)\n                    playNote(n, o, nil, primaryVoice, nil, 100, 1.0)\n                    state._syllableBtnHeld = {noteIndex = btn.noteIndex, label = btn.label, t = os.clock()}\n                    return\n                end\n            end\n        end\n        -- Template picker popup click\n        if state._solfegeTPBtns then\n            for vi, btn in ipairs(state._solfegeTPBtns) do\n                if x >= btn.x and x < btn.x + btn.w and y >= btn.y and y < btn.y + btn.h then\n                    local tp = state._solfegeTemplatePicker\n                    if tp then tp.sel = (tp.scrollTop or 0) + vi end\n                    _pushSolfegeTextUndoState(true)\n                    _acceptSolfegeTemplatePicker()\n                    return\n                end\n            end\n        end\n        -- Autocomplete popup click\n        if state._solfegeAcBtns then\n            for i, btn in ipairs(state._solfegeAcBtns) do\n                if x >= btn.x and x < btn.x + btn.w and y >= btn.y and y < btn.y + btn.h then\n                    local ac = state._solfegeAutocomplete\n                    if ac then ac.sel = i end\n                    _pushSolfegeTextUndoState(true)\n                    _acceptSolfegeAutocomplete()\n                    return\n                end\n            end\n        end\n        local ib = state._solfegeInputBounds\n        if ib and x >= ib.x and y >= ib.y and x < ib.x + ib.w and y < ib.y + ib.h then\n            if button == 3 then\n                showSolfegeCtxMenu(x, y, gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240); return\n            end\n            if not state.solfegeInputBuffer or state.solfegeInputBuffer == \"\" then\n                state.solfegeInputBuffer = state._solfegeSeqText or \"\"\n            end\n            state.solfegeInputActive = true\n            state.lyricNotesInputActive = false  -- transfer focus away from LN panel\n            _primeSolfegeTextHistory()\n            local _selLo, _selHi = solfegeSelRange()\n            local _clickAbsPos = solfegeTextPosFromPoint(x, y)\n            if _selLo and _clickAbsPos and not _shiftHeld and _clickAbsPos >= _selLo and _clickAbsPos < _selHi then\n                state._solfegeTextDragMove = {active = true, startX = x, startY = y, moved = false, dropPos = _clickAbsPos}\n                state._pendingSolfegeClick = nil\n                state._solfegeDragSel = false\n                return\n            end\n            do\n                local _now = getCurrentTimeMilliseconds()\n                local _dx, _dy = x - _solfegeLastClickX, y - _solfegeLastClickY\n                local isContinuation = (_now - _solfegeLastClickTime < DOUBLE_CLICK_MS)\n                    and (_dx*_dx + _dy*_dy <= DOUBLE_CLICK_RADIUS*DOUBLE_CLICK_RADIUS)\n                _solfegeClickCount = isContinuation and (_solfegeClickCount + 1) or 1\n                _solfegeLastClickTime = _now\n                _solfegeLastClickX, _solfegeLastClickY = x, y\n                state._pendingSolfegeClick = {\n                    x = x, y = y,\n                    isSelExtend = _shiftHeld,\n                    isDoubleClick = (_solfegeClickCount == 2),\n                    isTripleClick = (_solfegeClickCount >= 3),\n                }\n            end\n            -- Don't start drag-select on multi-click; word/line selection is handled in ui.lua\n            state._solfegeDragSel = (_solfegeClickCount == 1)\n            state._solfegeDragScrollTime = nil  -- reset auto-scroll rate limiter\n            state._solfegeDragRawY = nil\n            if not state._lyricsSnapshot then\n                local snap = {}\n                for i = 1, #(state.sequence or {}) do\n                    if state.sequence[i] then snap[i] = state.sequence[i].lyric end\n                end\n                state._lyricsSnapshot = snap\n            end\n        else\n            -- Click outside text box in secondary window → blur text input\n            if state.solfegeInputActive then\n                commitSolfegeInput()\n            end\n        end\n        return\n    end\n    local function tryHandleMasterVolumeSlider(mouseX, mouseY)\n        local bounds = state._masterVolumeSliderBounds\n        if not bounds then\n            return false\n        end\n        if mouseX < bounds.x or mouseX >= (bounds.x + bounds.width) then\n            return false\n        end\n        if mouseY < bounds.y or mouseY >= (bounds.y + bounds.height) then\n            return false\n        end\n\n        local sliderWidth = math.max(1, bounds.width - 1)\n        local normalized = (mouseX - bounds.x) / sliderWidth\n        setMasterVolume(normalized, true)\n        volumeSliderDrag.active = true\n        return true\n    end\n\n    -- Text box resize drag handle (left/right sidebar width)\n    local dh = state._solfegeDragHandle\n    if dh and x >= dh.x and y >= dh.y and x < dh.x + dh.w and y < dh.y + dh.h then\n        solfegeDrag.active = true\n        solfegeDrag.startX = x\n        solfegeDrag.startWidth = state.solfegeInputWidth or 130\n        solfegeDrag.side = dh.side\n        return\n    end\n\n    -- LN/text split drag handle\n    do\n        local sh = state._lnSplitHandle\n        if sh and x >= sh.x and y >= sh.y and x < sh.x + sh.w and y < sh.y + sh.h then\n            lnSplitDrag.active = true\n            lnSplitDrag.startX = x\n            lnSplitDrag.startRatio = math.max(0.2, math.min(0.65, state.lnSplitRatio or 0.38))\n            lnSplitDrag.totalW = sh.totalW or 200\n            state._lnSplitDragActive = true\n            return\n        end\n    end\n\n    -- Bottom panel top-edge height resize handle\n    local brh = state._solfegeBottomResizeHandle\n    if brh and x >= brh.x and y >= brh.y and x < brh.x + brh.w and y < brh.y + brh.h then\n        local curH = state.solfegeBottomH or 60\n        solfegeBottomDrag.active = true\n        solfegeBottomDrag.startY = y\n        solfegeBottomDrag.startH = curH\n        return\n    end\n\n    -- LN panel close/detach buttons must be checked before the LN toggle button,\n    -- which shares the same area and would otherwise intercept those clicks.\n    if state.lyricNotesPanelOpen and not state.lyricNotesDetached then\n        if handleLyricNotesPanelMouseDown(x, y, button, gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240) then return end\n    end\n\n    -- Lyrics toolbar buttons can sit high on screen in lyrics-only mode; handle them\n    -- before other main-window hit-tests so hidden timeline/header regions do not steal the click.\n    do\n        local lnBtn = state._solfegelyricNotesPanelBtn\n        if lnBtn and x >= lnBtn.x and y >= lnBtn.y and x < lnBtn.x + lnBtn.w and y < lnBtn.y + lnBtn.h then\n            state.lyricNotesPanelOpen = not state.lyricNotesPanelOpen\n            if state.lyricNotesPanelOpen then\n                state.lyricNotesBuffer = state.lyricNotesBuffer or \"\"\n                state.lyricEditingStepIndex = nil\n                state.lyricInputBuffer = nil\n                state.lyricNotesInputActive = false\n            end\n            markSequenceDirty()\n            return\n        end\n    end\n\n    -- Float panel title bar drag / resize handle\n    if state.solfegeTextInputSide == \"float\" then\n        local tb = state._solfegeFloatTitleBar\n        if tb and x >= tb.x and y >= tb.y and x < tb.x + tb.w and y < tb.y + tb.h then\n            solfegeFloatDrag.active = true\n            solfegeFloatDrag.startX = x\n            solfegeFloatDrag.startY = y\n            solfegeFloatDrag.startPanelX = state.solfegeFloatX or 40\n            solfegeFloatDrag.startPanelY = state.solfegeFloatY or 60\n            return\n        end\n        local rh = state._solfegeFloatResizeHandle\n        if rh and x >= rh.x and y >= rh.y and x < rh.x + rh.w and y < rh.y + rh.h then\n            solfegeFloatResize.active = true\n            solfegeFloatResize.startX = x\n            solfegeFloatResize.startY = y\n            solfegeFloatResize.startW = state.solfegeFloatW or 240\n            solfegeFloatResize.startH = state.solfegeFloatH or 180\n            return\n        end\n    end\n\n    -- Welcome screen intercepts all clicks\n    if showWelcomeScreen then\n        -- Scrollbar click/drag\n        local sb = state._welcomeScrollbar\n        if sb and x >= sb.hitX and x < sb.hitX + sb.hitW\n                and y >= sb.listTop and y < sb.listBottom then\n            if y >= sb.thumbY and y < sb.thumbY + sb.thumbH then\n                -- Click on thumb — start drag\n                welcomeScrollDrag.active = true\n                welcomeScrollDrag.dragOffsetY = y - sb.thumbY\n            else\n                -- Click on track — jump to position\n                local maxFirst = math.max(1, sb.total - sb.visibleCount + 1)\n                local ratio = (y - sb.trackTop - sb.thumbH / 2) / math.max(1, sb.availH - sb.thumbH)\n                local first = 1 + math.floor(ratio * (sb.total - sb.visibleCount) + 0.5)\n                state._welcomeFirstVisible = math.max(1, math.min(maxFirst, first))\n            end\n            return\n        end\n        local btns = state._welcomeBtns\n        if btns then\n            for _, btn in ipairs(btns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    if btn.action == \"new\" then\n                        newProjectFromWelcome()\n                    elseif btn.action == \"open\" then\n                        openProjectFromWelcome(btn.path)\n                    elseif btn.action == \"onedrive\" then\n                        connectOneDriveFromWelcome()\n                    elseif btn.action == \"import_docx\" then\n                        importDocxFromWelcome()\n                    elseif btn.action == \"templates\" then\n                        newProjectFromWelcome()\n                        showTemplateBrowser()\n                    elseif btn.action == \"open_file\" then\n                        local picker = rawget(_G, \"_webPickFile\")\n                        if picker then picker() end\n                    elseif btn.action == \"open_folder\" then\n                        openFolderInFinder(btn.path)\n                    end\n                    return\n                end\n            end\n        end\n        return\n    end\n\n    -- Ear training screen intercepts all clicks\n    if state.earTrainingMode then\n        local btns = state._earTrainingBtns\n        if btns then\n            for _, btn in ipairs(btns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    if btn.action == \"option\" then\n                        if not state.earTrainingRevealed then\n                            state.earTrainingSelectedOption = btn.index\n                            core._et.submit(btn.label)\n                        end\n                    elseif btn.action == \"replay\" then\n                        core._et.playQuestion()\n                    elseif btn.action == \"next\" then\n                        core._et.next()\n                    elseif btn.action == \"back\" then\n                        core._et.exit()\n                    elseif btn.action == \"difficulty\" then\n                        state.earTrainingDifficulty = btn.value\n                        core._et.next()\n                    elseif btn.action == \"submit_dictation\" then\n                        core._et.submit(state.earTrainingDictationInput)\n                    elseif btn.action == \"exercise_type\" then\n                        core._et.start(btn.value)\n                    end\n                    return\n                end\n            end\n        end\n        return\n    end\n\n    if button == 1 and isRunningOnMacDesktop() and tryHandleMasterVolumeSlider(x, y) then\n        return\n    end\n\n    -- Commit filename editing on any click outside the filename area.\n    if state.musicXMLFilenameEditing then\n        local fx = state._musicXMLHeaderX\n        local fy = state._musicXMLHeaderY\n        local fw = state._musicXMLHeaderW\n        local fh = state._musicXMLHeaderH\n        local insideFilename = fx and fy and fw and fh and\n            x >= fx and x < (fx + fw) and y >= fy and y < (fy + fh)\n        if not insideFilename then\n            commitMusicXMLFilenameEditing()\n        end\n    end\n\n    -- Top-row File menu should be able to open even while another dropdown is active.\n    if button == 1 and isRunningOnMacDesktop() then\n        local transportButtons = ui.getMacTransportButtons and ui.getMacTransportButtons() or nil\n        local fileButton = transportButtons and transportButtons.file\n        if fileButton and fileButton.x and fileButton.y and fileButton.width and fileButton.height\n            and x >= fileButton.x and x < (fileButton.x + fileButton.width)\n            and y >= fileButton.y and y < (fileButton.y + fileButton.height) then\n            local opening = not state.fileDropdownOpen\n            state.fileDropdownOpen = opening\n            state.editDropdownOpen = false\n            state.displayOptionsDropdownOpen = false\n            state.inputSourcesDropdownOpen = false\n            state.solfegeScaleDropdownOpen = false\n            state.keynoteDropdownOpen = false\n            state.viewActiveSubmenu = nil\n            if opening then\n                state._fileDropdownAnchorX = fileButton.x\n                state._fileDropdownAnchorY = fileButton.y + fileButton.height + 2\n                state.fileDropdownRecentFiles = findRecentMusicXMLFiles(8)\n                state.fileDropdownBackupFiles = findBackupFiles(20)\n                state.fileActiveSubmenu = nil\n                state._revertSubmenuScroll = 0\n            else\n                state.fileActiveSubmenu = nil\n            end\n            return\n        end\n    end\n\n    -- Step duration dropdown: intercept clicks while open\n    if state.stepDurDropdownOpen then\n        local dbtns = state._stepDurDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    setStepBeats(btn.value)\n                    savePreferences()\n                    state.stepDurDropdownOpen = false\n                    return\n                end\n            end\n        end\n        state.stepDurDropdownOpen = false\n    end\n\n    -- Meter dropdown: intercept clicks while open\n    if state.meterDropdownOpen then\n        local dbtns = state._meterDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    core.setTimeSignature(state, btn.numerator, btn.denominator)\n                    _syncKeyLineInTextBuffer()\n                    savePreferences()\n                    state.meterDropdownOpen = false\n                    return\n                end\n            end\n        end\n        state.meterDropdownOpen = false\n    end\n\n    -- Syllable dropdown: intercept clicks while open\n    if state.syllableDropdownOpen then\n        local octaveBtns = state._syllableDropdownOctaveBtns\n        if octaveBtns then\n            for _, btn in ipairs(octaveBtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    adjustSyllableDropdownOctave(btn.delta)\n                    return\n                end\n            end\n        end\n        local dbtns = state._syllableDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    applySyllableDropdownChoice(btn.note)\n                    return\n                end\n            end\n        end\n        -- Click outside dropdown: close it and let click propagate normally\n        state.syllableDropdownOpen = false\n    end\n\n    -- File dropdown: intercept clicks while open\n    if state.fileDropdownOpen then\n        -- Submenu items take priority\n        local sbtns = state._fileSubmenuBtns\n        if sbtns then\n            for _, btn in ipairs(sbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    do local _d=io.open(\"/tmp/solfege_debug.log\",\"a\"); if _d then _d:write(string.format(\"[submenu_click] key=%s path=%s time=%s\\n\",tostring(btn.key),tostring(btn.path),os.date(\"%H:%M:%S\"))); _d:close() end end\n                    if btn.key == \"revert_scroll_up\" then\n                        state._revertSubmenuScroll = math.max(0, (state._revertSubmenuScroll or 0) - 1)\n                        return\n                    elseif btn.key == \"revert_scroll_down\" then\n                        state._revertSubmenuScroll = (state._revertSubmenuScroll or 0) + 1\n                        return\n                    end\n                    state.fileDropdownOpen = false\n                    state.fileActiveSubmenu = nil\n                    if btn.key == \"open_recent\" then\n                        if btn.path then importMusicXML(btn.path) end\n                    elseif btn.key == \"revert\" then\n                        if btn.path then revertToBackup(btn.path) end\n                    elseif btn.key == \"import_musicxml\" then\n                        if platformAdapters.name == \"web\" then\n                            local picker = rawget(_G, \"_webPickFile\")\n                            if picker then picker() end\n                        else\n                            local path = pickFile(\"Select a MusicXML file\", \"musicxml,xml\")\n                            if path then importMusicXML(path) end\n                        end\n                    elseif btn.key == \"import_docx\" then\n                        local path = pickFile(\"Select a DOCX lyrics file\", \"docx\")\n                        if path then\n                            local ok = importDocxLyrics(path)\n                            if ok then state.linkedLyricsDocxPath = path end\n                        end\n                    elseif btn.key == \"export_musicxml\" then\n                        exportMusicXML(resolveMusicXMLStoragePath(\"export.musicxml\"))\n                    elseif btn.key == \"export_musicxml_as\" then\n                        exportMusicXML(buildMusicXMLSaveAsFilename())\n                    elseif btn.key == \"export_wav\" then\n                        exportAudioWav(buildAudioExportFilename(\"wav\"))\n                    elseif btn.key == \"export_mp3\" then\n                        exportAudioMp3(buildAudioExportFilename(\"mp3\"))\n                    elseif btn.key == \"export_docx\" then\n                        exportDocxLyrics(\"export.docx\")\n                    elseif btn.key == \"export_text_lyrics\" then\n                        exportPlainTextLyrics(\"export_lyrics.txt\")\n                    elseif btn.key == \"export_lyric_creator\" then\n                        exportLyricCreatorLyrics(\"export_cts1000v.txt\")\n                    elseif btn.key == \"reimport_linked_docx\" then\n                        local p = tostring(state.linkedLyricsDocxPath or \"\")\n                        if p ~= \"\" then importDocxLyrics(p) end\n                    elseif btn.key == \"unlink_docx\" then\n                        state.linkedLyricsDocxPath = nil\n                        showImportMessage = true\n                        importMessageTimer = 90\n                        importMessageText = \"DOCX unlinked\"\n                    end\n                    return\n                end\n            end\n        end\n        -- Parent menu items\n        local dbtns = state._fileDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    if btn.submenu then\n                        if state.fileActiveSubmenu == btn.submenu then\n                            state.fileActiveSubmenu = nil\n                        else\n                            state.fileActiveSubmenu = btn.submenu\n                            state._fileSubmenuAnchorY = btn.y\n                        end\n                    else\n                        state.fileDropdownOpen = false\n                        state.fileActiveSubmenu = nil\n                        if btn.key == \"new_project\" then\n                            createNewProject()\n                        elseif btn.key == \"save\" then\n                            saveSequence(true)\n                        elseif btn.key == \"templates\" then\n                            showTemplateBrowser()\n                        end\n                    end\n                    return\n                end\n            end\n        end\n        state.fileDropdownOpen = false\n        state.fileActiveSubmenu = nil\n    end\n\n    -- Edit dropdown: intercept clicks while open\n    if state.editDropdownOpen then\n        local dbtns = state._editDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    state.editDropdownOpen = false\n                    if btn.key == \"undo\" then\n                        if state.solfegeInputActive then undoSolfegeTextEdit() else undoStepChange() end\n                    elseif btn.key == \"redo\" then\n                        if state.solfegeInputActive then redoSolfegeTextEdit() else redoStepChange() end\n                    elseif btn.key == \"copy\" then\n                        if state.solfegeInputActive then\n                            copySolfegeSelection()\n                        elseif state.lyricNotesInputActive then\n                            copyLyricNotesSelection()\n                        elseif state.lyricEditingStepIndex then\n                            copyLyricStepSelection()\n                        end\n                    elseif btn.key == \"cut\" then\n                        if state.solfegeInputActive then\n                            cutSolfegeSelection()\n                        elseif state.lyricNotesInputActive then\n                            cutLyricNotesSelection()\n                        elseif state.lyricEditingStepIndex then\n                            cutLyricStepSelection()\n                        end\n                    elseif btn.key == \"paste\" then\n                        if state.solfegeInputActive then\n                            pasteIntoSolfegeText()\n                        elseif state.lyricNotesInputActive then\n                            pasteIntoLyricNotesText()\n                        elseif state.lyricEditingStepIndex then\n                            pasteIntoLyricStepText()\n                        end\n                    elseif btn.key == \"remove_empty_steps\" then\n                        removeEmptySteps()\n                    end\n                    return\n                end\n            end\n        end\n        state.editDropdownOpen = false\n    end\n\n    -- Display options dropdown: intercept clicks while open\n    if state.displayOptionsDropdownOpen then\n        -- Submenu items take priority\n        local sbtns = state._viewSubmenuBtns\n        if sbtns then\n            for _, btn in ipairs(sbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    toggleDisplayOption(btn.key)\n                    state.displayOptionsDropdownOpen = false\n                    state.viewActiveSubmenu = nil\n                    return\n                end\n            end\n        end\n        -- Parent menu items\n        local dbtns = state._displayOptionsDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    if state.viewActiveSubmenu == btn.submenu then\n                        state.viewActiveSubmenu = nil\n                    else\n                        state.viewActiveSubmenu = btn.submenu\n                        state._viewSubmenuAnchorY = btn.y\n                    end\n                    return\n                end\n            end\n        end\n        state.displayOptionsDropdownOpen = false\n        state.viewActiveSubmenu = nil\n    end\n\n    -- Input source dropdown: intercept clicks while open\n    if state.inputSourcesDropdownOpen then\n        local dbtns = state._inputSourcesDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    state.inputSourcesDropdownOpen = false\n                    if btn.key == \"midi_in\" then\n                        showMidiInDevicePicker()\n                    elseif btn.key == \"mic_input\" then\n                        showMicInputDevicePicker()\n                    elseif btn.key == \"gamepad\" then\n                        showGamepadDevicePicker()\n                    end\n                    return\n                end\n            end\n        end\n        state.inputSourcesDropdownOpen = false\n    end\n\n    -- Solfege scale dropdown: intercept clicks while open\n    if state.solfegeScaleDropdownOpen then\n        local dbtns = state._solfegeScaleDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    state.solfegeScaleDropdownOpen = false\n                    state._scaleDropdownSearch = nil\n                    state._scaleDropdownPending = nil\n                    if btn.key == \"custom\" then\n                        openScaleStepsInput()\n                    elseif state.solfegeScale ~= btn.key then\n                        local oldScale = state.solfegeScale or \"major\"\n                        state.solfegeScale = btn.key\n                        _scaleOps.remapNotesForScaleChange(oldScale, btn.key)\n                        refreshSolfegeNotes()\n                        snapKeyNoteToScale()\n                        markSequenceDirty()\n                        savePreferences()\n                    end\n                    return\n                end\n            end\n        end\n        state.solfegeScaleDropdownOpen = false\n        state._scaleDropdownSearch = nil\n        state._scaleDropdownPending = nil\n    end\n\n    -- Keynote dropdown: intercept clicks while open\n    if state.keynoteDropdownOpen then\n        local dbtns = state._keynoteDropdownBtns\n        if dbtns then\n            for _, btn in ipairs(dbtns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    state.keynoteDropdownOpen = false\n                    _scaleOps.setRootOnly(btn.note)\n                    return\n                end\n            end\n        end\n        state.keynoteDropdownOpen = false\n    end\n\n    -- Step context menu: intercept any click while open\n    if state.stepContextMenu then\n        local menu = state.stepContextMenu\n        local btns = state._stepContextMenuBtns\n        if button == 1 and btns then\n            for _, btn in ipairs(btns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    local si = menu.stepIndex\n                    if btn.action == \"before\" or btn.action == \"after\" then\n                        state.stepContextMenu = nil\n                        local insertAt = btn.action == \"before\" and si or si + 1\n                        tryInsertStepAt(insertAt)\n                    elseif btn.action == \"setLength\" then\n                        menu.mode = \"lengths\"\n                        menu.anchorX = menu.anchorX  -- keep position\n                    elseif btn.action == \"back\" then\n                        menu.mode = nil\n                    elseif type(btn.action) == \"table\" and btn.action.length then\n                        state.stepContextMenu = nil\n                        if core.stretchStep(state, si, btn.action.length) then\n                            markSequenceDirty()\n                            if state.soundPreviewOnNavigation and not state.isPlaying then\n                                local stepData = state.sequence[si]\n                                if stepData and not stepData.muted then\n                                    local len = core.getStepLength(stepData)\n                                    local gate = stepData.gate or 0.9\n                                    if core.isChord(stepData) then\n                                        local tn = {}\n                                        for _, nd in ipairs(stepData.notes) do\n                                            local n, o = transposeNoteForKey(nd.note, nd.octave)\n                                            table.insert(tn, {note = n, octave = o})\n                                        end\n                                        playChord(tn, si, primaryVoice, nil, len, gate)\n                                    elseif stepData.note and stepData.note ~= 13 then\n                                        local n, o = transposeNoteForKey(stepData.note, stepData.octave)\n                                        playNote(n, o, si, primaryVoice, nil, len, gate)\n                                    end\n                                end\n                            end\n                        end\n                    end\n                    return\n                end\n            end\n        end\n        state.stepContextMenu = nil\n        if button ~= 1 then return end\n        -- Left click outside menu: close and let click fall through\n    end\n\n    -- Right-click: check text inputs first, then steps\n    if button == 3 then\n        local _scrH = gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240\n        -- Solfege text input box\n        local _ib = state._solfegeInputBounds\n        if _ib and x >= _ib.x and y >= _ib.y and x < _ib.x + _ib.w and y < _ib.y + _ib.h then\n            showSolfegeCtxMenu(x, y, _scrH)\n            return\n        end\n        -- Lyric notes panel (when not detached — detached is handled via handleLyricNotesPanelMouseDown above)\n        if not state.lyricNotesDetached then\n            local _lnp = state._lyricNotesPanelBounds\n            if _lnp and x >= _lnp.x and y >= _lnp.y and x < _lnp.x + _lnp.width and y < _lnp.y + _lnp.height then\n                showLyricNotesCtxMenu(x, y, _scrH)\n                return\n            end\n        end\n        -- Step context menu (when not playing)\n        if not state.isPlaying then\n            local si = stepIndexAtPoint(x, y)\n            if si and state.sequence[si] then\n                state.stepContextMenu = {stepIndex = si, anchorX = x, anchorY = y}\n            end\n        end\n        return\n    end\n\n    local macTopRowH = isRunningOnMacDesktop() and 24 or 0\n    local macFilenameRowH = state._macFilenameRowH or 0\n    local headerTopY = macFilenameRowH + macTopRowH\n    local headerBottomY = headerTopY + 28\n    local row2TopY = headerBottomY\n    local row2BottomY = row2TopY + (((state.showToolsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\") and 28) or 0)\n    local subHeaderTopY = row2BottomY\n    local subHeaderBottomY = subHeaderTopY + (((state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\") and 24) or 0)\n\n    local inSubHeader = not state.singSolfegeMode and state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and y >= subHeaderTopY and y < subHeaderBottomY\n    local kx = state._keyHeaderX or 280\n    local kw = state._keyHeaderW or 40\n    local bx = state._bpmHeaderX or 340\n    local bw = state._bpmHeaderW or 50\n    local mxh = state._meterHeaderX or 310\n    local mwh = state._meterHeaderW or 30\n    local slx = state._seqLenHeaderX or 8\n    local slw = state._seqLenHeaderW or 60\n    local btx = state._beatsHeaderX or 0\n    local btw = state._beatsHeaderW or 0\n    local lhx = state._loopHeaderX or 0\n    local lhw = state._loopHeaderW or 0\n    local inKey     = inSubHeader and x >= (kx  - 4) and x < (kx  + kw  + 4)\n    local inLoop    = lhw > 0 and inSubHeader and x >= (lhx - 4) and x < (lhx + lhw + 4)\n    local inMeter   = inSubHeader and x >= (mxh - 4) and x < (mxh + mwh + 4)\n    local inBpm     = inSubHeader and x >= (bx  - 4) and x < (bx  + bw  + 4)\n    local inSeqLen  = inSubHeader and x >= (slx - 4) and x < (slx + slw + 4)\n    local inBeats   = btw > 0 and inSubHeader and x >= (btx - 4) and x < (btx + btw + 4)\n    local sdx = state._stepDurHeaderX or 0\n    local sdw = state._stepDurHeaderW or 0\n    local inStepDur = sdw > 0 and inSubHeader and x >= (sdx - 4) and x < (sdx + sdw + 4)\n    -- Click on position counter (playing/paused) → seek to bar; otherwise edit sequence length\n    if inSeqLen then\n        if state.isPlaying or state.isPaused then\n            state.seekEditing = true\n            state.seekInputBuffer = \"\"\n            state.barEditing = false\n            state.beatEditing = false\n        else\n            state.barEditing = true\n            state.barInputBuffer = \"\"\n            state.beatEditing = false\n            state.seekEditing = false\n        end\n        return\n    end\n    -- Click on Beats: start editing\n    if inBeats then\n        state.beatEditing = true\n        state.beatInputBuffer = \"\"\n        state.barEditing = false\n        return\n    end\n    -- Left-click on Step duration: next step size; right-click: previous\n    if inStepDur then\n        state.stepDurDropdownOpen = not state.stepDurDropdownOpen\n        state.meterDropdownOpen = false\n        return\n    end\n\n    -- Right-click on Key area: cycle key down and focus\n    if button == 2 and inKey then\n        state.keyFocused = true\n        state.bpmFocused = false\n        adjustRootNote(-1)\n        savePreferences()\n        return\n    end\n    -- Click on Loop status: toggle loop playback\n    if inLoop then\n        state.loopPlayback = not state.loopPlayback\n        return\n    end\n    -- Click on Meter area: open dropdown\n    if inMeter then\n        state.meterDropdownOpen = not state.meterDropdownOpen\n        state.stepDurDropdownOpen = false\n        return\n    end\n\n    -- Right-click on BPM area: start text editing + drag-to-scrub candidate\n    if button == 2 and inBpm then\n        bpmDrag.active = true\n        bpmDrag.startY = y\n        bpmDrag.startTempo = state.tempo\n        bpmDrag.moved = false\n        if not state.bpmEditing then\n            state.bpmEditing = true\n            state.bpmInputBuffer = tostring(state.tempo)\n            state.bpmInputCursor = #tostring(state.tempo)\n        else\n            local buf = state.bpmInputBuffer or \"\"\n            local positions = state._bpmCharXPositions\n            if positions and #buf > 0 then\n                local closest, closestDist = #buf, math.huge\n                for i, xPos in ipairs(positions) do\n                    local dist = math.abs(x - xPos)\n                    if dist < closestDist then closestDist = dist; closest = i - 1 end\n                end\n                state.bpmInputCursor = closest\n            end\n        end\n        state.bpmFocused = true\n        state.keyFocused = false\n        return\n    end\n    if button ~= 1 then return end -- Only handle left click for everything else\n    -- Clicking outside key/bpm areas clears focus\n    if not inKey then state.keyFocused = false end\n    if not inBpm then state.bpmFocused = false end\n\n    -- Handle template browser clicks\n    if state.showingTemplateBrowser then\n        -- \"< Back\" button in top-left\n        if x < 80 and y < 30 then\n            closeTemplateBrowser()\n            return\n        end\n    end\n\n    -- Handle mode select grid clicks\n    if state.showingModeSelect then\n        -- \"< Back\" button in top-left\n        if x < 80 and y < 30 then\n            closeModeSelectScreen()\n            return\n        end\n        local modes = getModeList()\n        local cols   = 2\n        local rows   = math.ceil(#modes / cols)\n        local startY = 38\n        local gridH  = 240 - startY - 22\n        local cellW  = math.floor(400 / cols)\n        local cellH  = math.floor(gridH / rows)\n        if y >= startY and y < startY + rows * cellH then\n            local col = math.floor(x / cellW)\n            local row = math.floor((y - startY) / cellH)\n            local idx = row * cols + col + 1\n            if idx >= 1 and idx <= #modes then\n                state.selectedModeOption = idx\n                callbacks.onPrimary()\n            end\n        end\n        return\n    end\n\n    -- Cancel BPM editing on any click outside the BPM area\n    if state.bpmEditing and not inBpm then\n        local v = tonumber(state.bpmInputBuffer)\n        if v then setTempo(v); savePreferences() end\n        state.bpmEditing = false\n        state.bpmInputBuffer = nil\n        state.bpmInputCursor = nil\n        state.headerSelection = -1\n    end\n    -- Commit bar/beat/seek editing on click outside\n    if state.barEditing and not inSeqLen then commitBarEdit() end\n    if state.beatEditing and not inBeats then commitBeatEdit() end\n    if state.seekEditing and not inSeqLen then commitSeekEdit() end\n\n    -- Commit lyric editing on clicks outside the active lyric editor.\n    if state.lyricEditingStepIndex then\n        local keepLyricEditorOpen = false\n        local lyricBounds = state._lyricStepInputBounds\n        if lyricBounds and state.lyricEditingStepIndex == lyricBounds.stepIndex then\n            keepLyricEditorOpen = x >= lyricBounds.x and y >= lyricBounds.y and x < lyricBounds.x + lyricBounds.w and y < lyricBounds.y + lyricBounds.h\n        end\n        if not keepLyricEditorOpen then\n            if commitLyricBufferToStep(state.lyricEditingStepIndex) then\n                markSequenceDirty()\n            end\n            state.lyricEditingStepIndex = nil\n            state.lyricInputBuffer = nil\n            state.lyricInputCursor = nil\n            state.lyricSelAnchor = nil\n            state.lyricSelFocus = nil\n            state._lyricStepDragSel = false\n        end\n    end\n\n    -- Solfege mode selector buttons (S / L / S+L)\n    -- Guard: skip if click is in the main header-button row (avoids overlap with Steps only / Lyrics / Compose / Sing)\n    local _inHeaderRow = (function()\n        local b = state._headerLyricsOnlyBtn or state._headerComposeModeBtn\n        return b and y >= b.y and y < b.y + b.h\n    end)()\n    do\n        local mbtns = (not _inHeaderRow) and state._solfegeModeBtns\n        if mbtns then\n            for _, mb in ipairs(mbtns) do\n                if x >= mb.x and y >= mb.y and x < mb.x + mb.w and y < mb.y + mb.h then\n                    -- Save buffer before commit so we can restore if re-serialization empties it.\n                    state._modeSwOldBuf = state.solfegeInputBuffer or \"\"\n                    if state.solfegeInputActive then commitSolfegeInput() end\n                    state.solfegeTextMode = mb.mode\n                    state.solfegeInputBuffer = serializeSequenceToText()\n                    state._solfegeSeqText = state.solfegeInputBuffer\n                    -- If the new serialization is empty but the user had typed content, restore it.\n                    if state.solfegeInputBuffer == \"\" and state._modeSwOldBuf ~= \"\" then\n                        state.solfegeInputBuffer = state._modeSwOldBuf\n                        state._solfegeSeqText = state.solfegeInputBuffer\n                    end\n                    state._modeSwOldBuf = nil\n                    state.solfegeInputActive = false\n                    savePreferences()\n                    return\n                end\n            end\n        end\n        -- LN button: toggle lyric notes panel\n        local lnBtn2 = (not _inHeaderRow) and state._solfegelyricNotesPanelBtn\n        if lnBtn2 and x >= lnBtn2.x and y >= lnBtn2.y and x < lnBtn2.x + lnBtn2.w and y < lnBtn2.y + lnBtn2.h then\n            state.lyricNotesPanelOpen = not state.lyricNotesPanelOpen\n            if state.lyricNotesPanelOpen then\n                state.lyricNotesBuffer = state.lyricNotesBuffer or \"\"\n                state.lyricEditingStepIndex = nil\n                state.lyricInputBuffer = nil\n                state.lyricNotesInputActive = false\n            end\n            markSequenceDirty(); return\n        end\n        local lyricsWinBtn = (not _inHeaderRow) and state._solfegeLyricsWindowBtn\n        if lyricsWinBtn and x >= lyricsWinBtn.x and y >= lyricsWinBtn.y and x < lyricsWinBtn.x + lyricsWinBtn.w and y < lyricsWinBtn.y + lyricsWinBtn.h then\n            if state.solfegeTextInputSide == \"window\" then\n                state.solfegeTextInputSide = \"bottom\"\n                local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n                if host and host.textWindow and host.textWindow.isOpen() then\n                    host.textWindow.close()\n                end\n                savePreferences()\n            else\n                openLyricsWindow()\n            end\n            return\n        end\n    end\n\n    -- Solfege font size dropdown\n    do\n        local smi = state._solfegeSizeMenuItems\n        if smi then\n            for _, item in ipairs(smi) do\n                if x >= item.x and y >= item.y and x < item.x + item.w and y < item.y + item.h then\n                    state.solfegeTextFontSize = item.value\n                    state._solfegeSizeMenuOpen = false\n                    savePreferences()\n                    return\n                end\n            end\n        end\n        local sbtn = state._solfegeSizeBtn\n        if sbtn and x >= sbtn.x and y >= sbtn.y and x < sbtn.x + sbtn.w and y < sbtn.y + sbtn.h then\n            state._solfegeSizeMenuOpen = not state._solfegeSizeMenuOpen\n            return\n        elseif state._solfegeSizeMenuOpen then\n            state._solfegeSizeMenuOpen = false\n        end\n    end\n\n    -- Selection bar (Copy / Cut / Paste popup below selection)\n    do\n        local selBar = state._solfegeSelBar\n        if selBar and selBar.btns then\n            for _, btn in ipairs(selBar.btns) do\n                if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    if btn.action == \"copy\" then copySolfegeSelection()\n                    elseif btn.action == \"cut\" then cutSolfegeSelection()\n                    elseif btn.action == \"paste\" then pasteIntoSolfegeText()\n                    end\n                    state.solfegeInputActive = true\n                    return\n                end\n            end\n        end\n    end\n\n    -- Clipboard buttons (Copy / Cut / Paste)\n    do\n        local cb = state._solfegeCopyBtn\n        if cb and x >= cb.x and y >= cb.y and x < cb.x + cb.w and y < cb.y + cb.h then\n            copySolfegeSelection()\n            return\n        end\n        local xb = state._solfegeCutBtn\n        if xb and x >= xb.x and y >= xb.y and x < xb.x + xb.w and y < xb.y + xb.h then\n            cutSolfegeSelection()\n            return\n        end\n        local vb = state._solfegePasteBtn\n        if vb and x >= vb.x and y >= vb.y and x < vb.x + vb.w and y < vb.y + vb.h then\n            pasteIntoSolfegeText()\n            return\n        end\n    end\n\n    -- Show/hide break markers toggle\n    do\n        local bt = state._solfegeBreakToggleBtn\n        if bt and x >= bt.x and y >= bt.y and x < bt.x + bt.w and y < bt.y + bt.h then\n            state.solfegeShowBreaks = not (state.solfegeShowBreaks ~= false)\n            savePreferences(); return\n        end\n    end\n\n    -- Spell check toggle (SP)\n    do\n        local sp = state._solfegeSpellBtn\n        if sp and x >= sp.x and y >= sp.y and x < sp.x + sp.w and y < sp.y + sp.h then\n            state.solfegeSpellCheck = not (state.solfegeSpellCheck == true)\n            if state.solfegeSpellCheck then _spellCheckPending = true else state._solfegeSpellErrors = nil end\n            savePreferences(); return\n        end\n        local tmb = state._solfegeTemplateBtn\n        if tmb and x >= tmb.x and y >= tmb.y and x < tmb.x + tmb.w and y < tmb.y + tmb.h then\n            local opening = not (state._solfegeTemplateMenuOpen == true)\n            state._solfegeTemplateMenuOpen = opening\n            if opening then\n                state._solfegeTemplateSearch = \"\"\n                state._solfegeTemplateCollapsed = {}\n            else\n                state._solfegeTemplateSaving = nil\n                state._solfegeTemplateSaveName = nil\n                state._solfegeTemplateRenaming = nil\n            end\n            return\n        elseif state._solfegeTemplateMenuOpen then\n            -- Category header collapse toggles (only while menu is open)\n            local tch = state._solfegeTemplateCategoryHeaders\n            if tch then\n                for _, hdr in ipairs(tch) do\n                    if x >= hdr.x and y >= hdr.y and x < hdr.x + hdr.w and y < hdr.y + hdr.h then\n                        if not state._solfegeTemplateCollapsed then state._solfegeTemplateCollapsed = {} end\n                        state._solfegeTemplateCollapsed[hdr.name] = not state._solfegeTemplateCollapsed[hdr.name]\n                        return\n                    end\n                end\n            end\n            local tmi = state._solfegeTemplateMenuItems\n            if tmi then\n                for _, item in ipairs(tmi) do\n                    if x >= item.x and y >= item.y and x < item.x + item.w and y < item.y + item.h then\n                        if item.type == \"search_input\" then\n                            -- no-op\n                        elseif item.type == \"mode_toggle\" then\n                            if item.replaceX and x >= item.replaceX then\n                                state._solfegeTemplateReplaceMode = true\n                            else\n                                state._solfegeTemplateReplaceMode = false\n                            end\n                        elseif item.type == \"save\" then\n                            state._solfegeTemplateSaving = true\n                            state._solfegeTemplateSaveName = \"\"\n                        elseif item.type == \"save_input\" then\n                            -- no-op\n                        elseif item.type == \"save_flash\" or item.type == \"save_disabled\" then\n                            -- no-op\n                        elseif item.type == \"user_item\" then\n                            if item.deleteX and x >= item.deleteX then\n                                deleteUserSolfegeTemplate(item.userIndex)\n                                state._solfegeTemplateRenaming = nil\n                                state._solfegeTemplateSaving = nil\n                                state._solfegeTemplateSaveName = nil\n                                state._solfegeTemplateMenuOpen = false\n                            elseif item.renameX and x >= item.renameX then\n                                state._solfegeTemplateRenaming = {index = item.userIndex, name = item.name}\n                            else\n                                state._solfegeTemplateMenuOpen = false\n                                state._solfegeTemplateSaving = nil\n                                state._solfegeTemplateSaveName = nil\n                                state._solfegeTemplateRenaming = nil\n                                insertSolfegeTemplateText(item.text)\n                            end\n                        else\n                            state._solfegeTemplateMenuOpen = false\n                            state._solfegeTemplateSaving = nil\n                            state._solfegeTemplateSaveName = nil\n                            state._solfegeTemplateRenaming = nil\n                            insertSolfegeTemplateText(item.text)\n                        end\n                        return\n                    end\n                end\n            end\n            -- Click outside menu while open — close it\n            state._solfegeTemplateMenuOpen = false\n            state._solfegeTemplateSaving = nil\n            state._solfegeTemplateSaveName = nil\n            state._solfegeTemplateRenaming = nil\n        end\n        -- All-options list button (≣)\n        local aob = state._solfegeAllOptionsBtn\n        if aob and x >= aob.x and y >= aob.y and x < aob.x + aob.w and y < aob.y + aob.h then\n            state._solfegeAllOptionsOpen = not state._solfegeAllOptionsOpen\n            if state._solfegeAllOptionsOpen then state._allOptionsPanelX = nil end\n            return\n        end\n        -- All-options floating window interactions\n        if state._solfegeAllOptionsOpen then\n            local cb = state._solfegeAllOptionsCloseBtn\n            if cb and x >= cb.x and y >= cb.y and x < cb.x + cb.w and y < cb.y + cb.h then\n                state._solfegeAllOptionsOpen = false; return\n            end\n            local tb = state._solfegeAllOptionsTitleBar\n            if tb and x >= tb.x and y >= tb.y and x < tb.x + tb.w and y < tb.y + tb.h then\n                state._allOptionsDragActive = true\n                state._allOptionsDragOX = x - (state._allOptionsPanelX or 0)\n                state._allOptionsDragOY = y - (state._allOptionsPanelY or 0)\n                return\n            end\n            local allBtns = state._solfegeAllOptionsBtns\n            if allBtns then\n                for _, btn in ipairs(allBtns) do\n                    if x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                        insertSolfegeTemplateText(btn.text); return\n                    end\n                end\n            end\n        end\n    end\n\n    -- Key/octave change buttons (steps mode only)\n    do\n        local kmb = state._solfegeKeyMinusBtn\n        if kmb and x >= kmb.x and y >= kmb.y and x < kmb.x + kmb.w and y < kmb.y + kmb.h then\n            _scaleOps.setRootOnly(((state.rootNote or 0) - 1 + 12) % 12); return\n        end\n        local kpb = state._solfegeKeyPlusBtn\n        if kpb and x >= kpb.x and y >= kpb.y and x < kpb.x + kpb.w and y < kpb.y + kpb.h then\n            _scaleOps.setRootOnly(((state.rootNote or 0) + 1) % 12); return\n        end\n        local omb = state._solfegeOctMinusBtn\n        if omb and x >= omb.x and y >= omb.y and x < omb.x + omb.w and y < omb.y + omb.h then\n            local cur = state.currentOctave or 4\n            if cur > 1 then shiftAllStepOctaves(-1); state.currentOctave = cur - 1 end\n            _syncKeyLineInTextBuffer(); return\n        end\n        local opb = state._solfegeOctPlusBtn\n        if opb and x >= opb.x and y >= opb.y and x < opb.x + opb.w and y < opb.y + opb.h then\n            local cur = state.currentOctave or 4\n            if cur < 8 then shiftAllStepOctaves(1); state.currentOctave = cur + 1 end\n            _syncKeyLineInTextBuffer(); return\n        end\n    end\n\n    -- Note length toggle (/4)\n    do\n        local nlb = state._solfegeNoteLengthBtn\n        if nlb and x >= nlb.x and y >= nlb.y and x < nlb.x + nlb.w and y < nlb.y + nlb.h then\n            state.showNoteLengths = not (state.showNoteLengths ~= false)\n            markSequenceDirty()\n            savePreferences(); return\n        end\n    end\n\n    -- Octave number toggle (O4)\n    do\n        local ob = state._solfegeOctToggleBtn\n        if ob and x >= ob.x and y >= ob.y and x < ob.x + ob.w and y < ob.y + ob.h then\n            state.showOctaveNumbers = not (state.showOctaveNumbers ~= false)\n            markSequenceDirty()\n            savePreferences(); return\n        end\n    end\n\n    -- Text-only toggle (T)\n    do\n        local tb = state._solfegeTextOnlyBtn\n        if tb and x >= tb.x and y >= tb.y and x < tb.x + tb.w and y < tb.y + tb.h then\n            state.solfegeTextOnlyMode = not (state.solfegeTextOnlyMode == true)\n            savePreferences(); return\n        end\n    end\n\n    -- Solfege paragraph end button (¶)\n    do\n        local pb = state._solfegeParagraphBtn\n        if pb and x >= pb.x and y >= pb.y and x < pb.x + pb.w and y < pb.y + pb.h then\n            if not state.solfegeInputBuffer or state.solfegeInputBuffer == \"\" then\n                state.solfegeInputBuffer = state._solfegeSeqText or \"\"\n            end\n            _pushSolfegeTextUndoState(true)\n            local buf = state.solfegeInputBuffer or \"\"\n            local cur = state.solfegeInputCursor\n            local marker = \"\\n\\n\"\n            if cur then\n                local before = buf:sub(1, cur - 1):gsub(\"%s+$\", \"\")\n                local after = buf:sub(cur):gsub(\"^%s+\", \"\")\n                state.solfegeInputBuffer = before .. marker .. after\n                state.solfegeInputCursor = #before + #marker + 1\n            else\n                state.solfegeInputBuffer = buf:gsub(\"%s+$\", \"\") .. marker\n            end\n            state.solfegeInputActive = true\n            state._solfegeLastCursorActivity = os.clock()\n            _requestLiveApply(true)\n            return\n        end\n    end\n\n    -- Solfege dock buttons: ◀ ▼ ▶ ⊟ ⧉ to set text box position\n    do\n        local dbtns = state._solfegeDockBtns\n        if dbtns then\n            for _, db in ipairs(dbtns) do\n                if x >= db.x and y >= db.y and x < db.x + db.w and y < db.y + db.h then\n                    local prevSide = state.solfegeTextInputSide or \"bottom\"\n                    state.solfegeTextInputSide = db.side\n                    if db.side ~= \"bottom\" then state.solfegeTextOnlyMode = false end\n                    if db.side == \"float\" and not state.solfegeFloatX then\n                        state.solfegeFloatX = 40\n                        state.solfegeFloatY = 60\n                        state.solfegeFloatW = 240\n                        state.solfegeFloatH = 180\n                    end\n                    -- Open or close the secondary OS window as needed\n                    local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n                    if host and host.textWindow then\n                        if db.side == \"window\" then\n                            if not host.textWindow.isOpen() then\n                                host.textWindow.open(\"Text Input\", 600, 480)\n                            end\n                        elseif prevSide == \"window\" then\n                            host.textWindow.close()\n                        end\n                    end\n                    savePreferences()\n                    return\n                end\n            end\n        end\n    end\n\n    -- Solfege syllable button click (must be before panel bounds check)\n    if state._solfegeSyllableBtns then\n        for _, btn in ipairs(state._solfegeSyllableBtns) do\n            if x >= btn.x and x < btn.x + btn.w and y >= btn.y and y < btn.y + btn.h then\n                if state._playbackEndingGrace and not state.isPlaying then\n                    state.isPlaying = true\n                    state._playbackEndingGrace = false\n                    playbackClock.nextStepAt = getCurrentTimeMilliseconds()\n                end\n                local n, o = transposeNoteForKey(btn.noteIndex, state.currentOctave or 4)\n                playNote(n, o, nil, primaryVoice, nil, 100, 1.0)\n                state._syllableBtnHeld = {noteIndex = btn.noteIndex, label = btn.label, t = os.clock()}\n                return\n            end\n        end\n    end\n\n    -- Solfege input field click to focus/blur\n    do\n        -- Template picker popup click\n        if state._solfegeTPBtns then\n            for vi, btn in ipairs(state._solfegeTPBtns) do\n                if x >= btn.x and x < btn.x + btn.w and y >= btn.y and y < btn.y + btn.h then\n                    local tp = state._solfegeTemplatePicker\n                    if tp then tp.sel = (tp.scrollTop or 0) + vi end\n                    _pushSolfegeTextUndoState(true)\n                    _acceptSolfegeTemplatePicker()\n                    return\n                end\n            end\n        end\n        -- Autocomplete popup click\n        if state._solfegeAcBtns then\n            for i, btn in ipairs(state._solfegeAcBtns) do\n                if x >= btn.x and x < btn.x + btn.w and y >= btn.y and y < btn.y + btn.h then\n                    local ac = state._solfegeAutocomplete\n                    if ac then ac.sel = i end\n                    _pushSolfegeTextUndoState(true)\n                    _acceptSolfegeAutocomplete()\n                    return\n                end\n            end\n        end\n        local ib = state._solfegeInputBounds\n        if ib and x >= ib.x and y >= ib.y and x < ib.x + ib.w and y < ib.y + ib.h then\n            if button == 3 then\n                showSolfegeCtxMenu(x, y, gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240); return\n            end\n            -- Load buffer first so the next render frame builds _solfegeDispLines\n            -- from the correct text before cursor positioning runs.\n            if not state.solfegeInputBuffer or state.solfegeInputBuffer == \"\" then\n                local seqText = state._solfegeSeqText\n                state.solfegeInputBuffer = (seqText and seqText ~= \"\") and seqText or \"\"\n            end\n            state.solfegeInputActive = true\n            _primeSolfegeTextHistory()\n            local _selLo2, _selHi2 = solfegeSelRange()\n            local _clickAbsPos2 = solfegeTextPosFromPoint(x, y)\n            if _selLo2 and _clickAbsPos2 and not _shiftHeld and _clickAbsPos2 >= _selLo2 and _clickAbsPos2 < _selHi2 then\n                state._solfegeTextDragMove = {active = true, startX = x, startY = y, moved = false, dropPos = _clickAbsPos2}\n                state._pendingSolfegeClick = nil\n                state._solfegeDragSel = false\n                return\n            end\n            -- Defer cursor positioning to the next render frame (ui.lua).\n            -- _solfegeDispLines is rebuilt there from the now-current buffer,\n            -- so character boundary math is always correct.\n            do\n                local _now = getCurrentTimeMilliseconds()\n                local _dx, _dy = x - _solfegeLastClickX, y - _solfegeLastClickY\n                local isContinuation = (_now - _solfegeLastClickTime < DOUBLE_CLICK_MS)\n                    and (_dx*_dx + _dy*_dy <= DOUBLE_CLICK_RADIUS*DOUBLE_CLICK_RADIUS)\n                _solfegeClickCount = isContinuation and (_solfegeClickCount + 1) or 1\n                _solfegeLastClickTime = _now\n                _solfegeLastClickX, _solfegeLastClickY = x, y\n                state._pendingSolfegeClick = {\n                    x = x, y = y,\n                    isSelExtend = _shiftHeld,\n                    isDoubleClick = (_solfegeClickCount == 2),\n                    isTripleClick = (_solfegeClickCount >= 3),\n                }\n            end\n            -- Don't start drag-select on multi-click; word/line selection is handled in ui.lua\n            state._solfegeDragSel = (_solfegeClickCount == 1)\n            state._solfegeDragScrollTime = nil  -- reset auto-scroll rate limiter\n            state._solfegeDragRawY = nil\n            -- Snapshot lyrics once at the start of editing so mid-parse rewrites\n            -- (from partial tokens) don't lose lyrics on subsequent keystrokes\n            if not state._lyricsSnapshot then\n                local snap = {}\n                local sLen = state.sequenceLength or 0\n                for i = 1, sLen do\n                    if state.sequence[i] then snap[i] = state.sequence[i].lyric end\n                end\n                state._lyricsSnapshot = snap\n            end\n            _requestLiveApply(true)\n            return\n        elseif state.solfegeInputActive then\n            state.solfegeInputActive = false\n            state.solfegeInputCursor = nil\n            state._lyricsSnapshot = nil\n            state._pendingSolfegeClick = nil\n        end\n    end\n\n    -- Handle clicks on Mic Input device picker\n    if state.showingMicInputPicker then\n        if x < 80 and y < 30 then\n            closeMicInputDevicePicker()\n            showModeSelectScreen()\n            return\n        end\n        local devices = state.micInputPickerDevices or {}\n        local startY = 50\n        local lineHeight = 25\n        local instructY = 220\n        local visibleCount = math.floor((instructY - startY) / lineHeight)\n        local total = #devices\n        local maxFirst = math.max(1, total - visibleCount + 1)\n        local firstVisible = math.max(1, state.micInputPickerSelection - visibleCount + 1)\n        firstVisible = math.min(firstVisible, maxFirst)\n        if y >= startY and y < startY + visibleCount * lineHeight then\n            local row = math.floor((y - startY) / lineHeight)\n            local idx = firstVisible + row\n            if idx >= 1 and idx <= total then\n                local dev = devices[idx]\n                state.micInputPickerSelection = idx\n                connectMicInputDevice(dev)\n                closeMicInputDevicePicker()\n                showModeSelectScreen()\n            end\n        end\n        return\n    end\n\n    -- Handle clicks on Gamepad device picker\n    if state.showingGamepadPicker then\n        if x < 80 and y < 30 then\n            closeGamepadDevicePicker()\n            showModeSelectScreen()\n            return\n        end\n        local devices = state.gamepadPickerDevices or {}\n        local startY = 50\n        local lineHeight = 25\n        local instructY = 220\n        local visibleCount = math.floor((instructY - startY) / lineHeight)\n        local total = #devices\n        local maxFirst = math.max(1, total - visibleCount + 1)\n        local firstVisible = math.max(1, state.gamepadPickerSelection - visibleCount + 1)\n        firstVisible = math.min(firstVisible, maxFirst)\n        if y >= startY and y < startY + visibleCount * lineHeight then\n            local row = math.floor((y - startY) / lineHeight)\n            local idx = firstVisible + row\n            if idx >= 1 and idx <= total then\n                local dev = devices[idx]\n                state.gamepadPickerSelection = idx\n                connectGamepadDevice(dev)\n                closeGamepadDevicePicker()\n                showModeSelectScreen()\n            end\n        end\n        return\n    end\n\n    -- Handle clicks on MIDI Controls screen\n    if state.showingMidiControls then\n        if x < 80 and y < 30 then\n            if state.midiLearnMode then\n                state.midiLearnMode = false\n                state.midiLearnTarget = nil\n            else\n                closeMidiControlsScreen()\n                showModeSelectScreen()\n            end\n            return\n        end\n        local startY = 50\n        local lineHeight = 25\n        local instructY = 220\n        local visibleCount = math.floor((instructY - startY) / lineHeight)\n        -- Compute scroll offset matching ui.lua\n        local sel = state.midiControlsSelection or 1\n        local firstVisible = math.max(1, sel - visibleCount + 1)\n        firstVisible = math.min(firstVisible, math.max(1, #MIDI_ACTIONS - visibleCount + 1))\n        if y >= startY and y < startY + visibleCount * lineHeight then\n            local row = math.floor((y - startY) / lineHeight)\n            local idx = firstVisible + row\n            if idx >= 1 and idx <= #MIDI_ACTIONS then\n                state.midiControlsSelection = idx\n                local act = MIDI_ACTIONS[idx]\n                -- Left-click = start learn; right-click = clear\n                if button == 3 then\n                    state.midiMappings[act.id] = nil\n                    state.midiLearnMode = false\n                    state.midiLearnTarget = nil\n                    savePreferences()\n                else\n                    state.midiLearnMode = true\n                    state.midiLearnTarget = act.id\n                end\n            end\n        end\n        return\n    end\n\n    -- Handle clicks on MIDI In device picker\n    if state.showingMidiInPicker then\n        -- ♬ button click toggles the picker closed (same button that opened it)\n        if isRunningOnMacDesktop() then\n            local tb = ui.getMacTransportButtons and ui.getMacTransportButtons()\n            local inputBtn = tb and tb.input\n            if inputBtn and inputBtn.y\n               and x >= inputBtn.x and x < (inputBtn.x + inputBtn.width)\n               and y >= inputBtn.y and y < (inputBtn.y + inputBtn.height) then\n                closeMidiInDevicePicker()\n                return\n            end\n        end\n        -- \"< Back\" button in top-left: close picker and stay in edit mode\n        if x < 80 and y < 30 then\n            closeMidiInDevicePicker()\n            return\n        end\n        local devices = state.midiInPickerDevices or {}\n        local startY = 50\n        local lineHeight = 25\n        local instructY = 220  -- SCREEN_H - 20\n        local visibleCount = math.floor((instructY - startY) / lineHeight)\n        local total = #devices\n        local maxFirst = math.max(1, total - visibleCount + 1)\n        local firstVisible = math.max(1, state.midiInPickerSelection - visibleCount + 1)\n        firstVisible = math.min(firstVisible, maxFirst)\n        if y >= startY and y < startY + visibleCount * lineHeight then\n            local row = math.floor((y - startY) / lineHeight)\n            local idx = firstVisible + row\n            if idx >= 1 and idx <= total then\n                local dev = devices[idx]\n                connectMidiInDevice(dev)\n                closeMidiInDevicePicker()\n                -- Switch to edit mode so the connected keyboard can immediately input notes\n                state.singSolfegeMode = false\n                syncPitchRecognitionForSingSolfege()\n                savePreferences()\n            end\n        end\n        return\n    end\n\n    local isMacDesktop = isRunningOnMacDesktop()\n\n    -- Home button works on all platforms (position set during render)\n    do\n        local transportButtons = ui.getMacTransportButtons and ui.getMacTransportButtons() or nil\n        if transportButtons then\n            local homeButton = transportButtons.home\n            if homeButton and homeButton.x and homeButton.y and homeButton.width and homeButton.height\n                and x >= homeButton.x and x < (homeButton.x + homeButton.width)\n                and y >= homeButton.y and y < (homeButton.y + homeButton.height) then\n                if state.isPlaying then pausePlayback() end\n                welcomeRecentFiles = findRecentMusicXMLFiles(50)\n                showWelcomeScreen = true\n                state._welcomeFirstVisible = 1\n                return\n            end\n        end\n    end\n\n    if isMacDesktop then\n        local transportButtons = ui.getMacTransportButtons and ui.getMacTransportButtons() or nil\n        if transportButtons then\n            local micButton   = transportButtons.mic\n            local editButton  = transportButtons.edit\n            local loopButton  = transportButtons.loop\n            local beginningButton = transportButtons.beginning\n            local playButton  = transportButtons.play\n            local stopButton  = transportButtons.stop\n            local volumeButton = transportButtons.volume\n            local function inTransportButton(btn)\n                return btn and btn.x and btn.y and btn.width and btn.height\n                    and x >= btn.x and x < (btn.x + btn.width)\n                    and y >= btn.y and y < (btn.y + btn.height)\n            end\n\n            if state.showMicRow ~= false and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and inTransportButton(micButton) then\n                if canRecordMicStepInput() then\n                    state.micStepRecording = not state.micStepRecording\n                    if not state.micStepRecording then\n                        state.pitchHoldMs = 0\n                        state.pitchHoldAchieved = false\n                    end\n                    syncPitchRecognitionForSingSolfege()\n                else\n                    state.micStepRecording = false\n                end\n                savePreferences()\n                return\n            end\n\n            local keynoteButton = transportButtons.keynote\n            if inTransportButton(keynoteButton) then\n                state._keynoteDropdownAnchorX = keynoteButton.x\n                state._keynoteDropdownAnchorY = keynoteButton.y + keynoteButton.height + 2\n                state.keynoteDropdownOpen = not state.keynoteDropdownOpen\n                return\n            end\n\n            local setkeyButton = transportButtons.setkey\n            if inTransportButton(setkeyButton) then\n                _scaleOps.bakeKeyIntoSteps()\n                return\n            end\n\n            local muteButton = transportButtons.mute\n            if inTransportButton(muteButton) then\n                state.muteStepPreview = not state.muteStepPreview\n                if state.muteStepPreview then stopVoice(primaryVoice) end\n                return\n            end\n\n            local viewButton = transportButtons.view\n            if inTransportButton(viewButton) then\n                state._displayOptionsDropdownAnchorX = viewButton.x\n                state._displayOptionsDropdownAnchorY = viewButton.y + viewButton.height + 2\n                state.displayOptionsDropdownOpen = not state.displayOptionsDropdownOpen\n                if not state.displayOptionsDropdownOpen then state.viewActiveSubmenu = nil end\n                return\n            end\n\n            local scaleButton = transportButtons.scale\n            if inTransportButton(scaleButton) then\n                state._solfegeScaleDropdownAnchorX = scaleButton.x\n                state._solfegeScaleDropdownAnchorY = scaleButton.y + scaleButton.height + 2\n                state.solfegeScaleDropdownOpen = not state.solfegeScaleDropdownOpen\n                state._scaleDropdownSearch = nil\n                state._scaleDropdownPending = nil\n                return\n            end\n\n            local volumeBounds = state._masterVolumeSliderBounds\n            if volumeBounds and inTransportButton(volumeButton) then\n                local sliderWidth = math.max(1, volumeBounds.width - 1)\n                local normalized = (x - volumeBounds.x) / sliderWidth\n                setMasterVolume(normalized, true)\n                return\n            end\n\n            if inTransportButton(editButton) then\n                state._editDropdownAnchorX = editButton.x\n                state._editDropdownAnchorY = editButton.y + editButton.height + 2\n                state.editDropdownOpen = not state.editDropdownOpen\n                return\n            end\n\n            if inTransportButton(loopButton) then\n                state.loopPlayback = not state.loopPlayback\n                _syncKeyLineInTextBuffer()\n                savePreferences()\n                return\n            end\n\n            if inTransportButton(beginningButton) then\n                state.currentPlaybackSequenceIndex = state.activeSequenceIndex or 1\n                local loopStart = select(1, getEnabledStepLoopRange(state.currentPlaybackSequenceIndex, state.sequenceLength or 0))\n                state.playbackPosition = loopStart or 1\n                state.currentPlaybackStep = state.playbackPosition\n                return\n            end\n\n            if inTransportButton(playButton) then\n                if state.isPlaying then\n                    pausePlayback()\n                else\n                    startPlayback()\n                end\n                return\n            end\n\n            if inTransportButton(stopButton) then\n                if state.isPlaying or state.isPaused then\n                    stopPlayback()\n                end\n                return\n            end\n\n            if state.showPlaybackRow ~= false and (state.solfegeTextMode or \"both\") ~= \"lyrics\" then\n                local recButton = transportButtons.rec\n                if inTransportButton(recButton) then\n                    state.midiLiveRecord = not state.midiLiveRecord\n                    return\n                end\n            end\n\n            local inputButton = transportButtons.input\n            if inTransportButton(inputButton) then\n                if state.showingMidiInPicker then closeMidiInDevicePicker() end\n                if state.showingMicInputPicker then closeMicInputDevicePicker() end\n                if state.showingGamepadPicker then closeGamepadDevicePicker() end\n\n                state._inputSourcesDropdownAnchorX = inputButton.x\n                state._inputSourcesDropdownAnchorY = inputButton.y + inputButton.height + 2\n                state.inputSourcesDropdownOpen = not state.inputSourcesDropdownOpen\n                return\n            end\n\n            local fileButton = transportButtons.file\n            if inTransportButton(fileButton) then\n                state._fileDropdownAnchorX = fileButton.x\n                state._fileDropdownAnchorY = fileButton.y + fileButton.height + 2\n                if not state.fileDropdownOpen then\n                    state.fileDropdownRecentFiles = findRecentMusicXMLFiles(8)\n                    state.fileDropdownBackupFiles = findBackupFiles(20)\n                    state.fileActiveSubmenu = nil\n                    state._revertSubmenuScroll = 0\n                end\n                state.fileDropdownOpen = not state.fileDropdownOpen\n                return\n            end\n\n        end\n    end\n\n    -- Path popup: intercept clicks while open\n    if state.pathPopupOpen then\n        local pbtns = state._pathPopupBtns\n        if pbtns then\n            for _, btn in ipairs(pbtns) do\n                if btn and x >= btn.x and y >= btn.y and x < btn.x + btn.w and y < btn.y + btn.h then\n                    state.pathPopupOpen = false\n                    if not btn.isFile then\n                        openFolderInFinder(btn.path)\n                    end\n                    return\n                end\n            end\n        end\n        state.pathPopupOpen = false\n        return\n    end\n\n    -- Folder icon click: toggle path hierarchy popup\n    local folderIconX = state._folderIconX\n    local folderIconY = state._folderIconY\n    local folderIconW = state._folderIconW\n    local folderIconH = state._folderIconH\n    if button == 1 and folderIconX and folderIconY and folderIconW and folderIconH and\n        x >= folderIconX and x < (folderIconX + folderIconW) and y >= folderIconY and y < (folderIconY + folderIconH) then\n        state.pathPopupOpen = not state.pathPopupOpen\n        return\n    end\n\n    local fileNameX = state._musicXMLHeaderX\n    local fileNameY = state._musicXMLHeaderY\n    local fileNameW = state._musicXMLHeaderW\n    local fileNameH = state._musicXMLHeaderH\n    if button == 1 and fileNameX and fileNameY and fileNameW and fileNameH and\n        x >= fileNameX and x < (fileNameX + fileNameW) and y >= fileNameY and y < (fileNameY + fileNameH) then\n        if state.musicXMLFilenameEditing then\n            -- Move cursor to clicked character position\n            local positions = state._filenameCharXPositions\n            if positions then\n                local closestPos = 0\n                local closestDist = math.huge\n                for i, xPos in ipairs(positions) do\n                    local dist = math.abs(x - xPos)\n                    if dist < closestDist then\n                        closestDist = dist\n                        closestPos = i - 1  -- 0-based cursor\n                    end\n                end\n                state.musicXMLFilenameInputCursor = closestPos\n            end\n        else\n            startMusicXMLFilenameEditing()\n        end\n        return\n    end\n\nif state._headerLyricsOnlyBtn\n   and x >= state._headerLyricsOnlyBtn.x and x < state._headerLyricsOnlyBtn.x + state._headerLyricsOnlyBtn.w\n   and y >= state._headerLyricsOnlyBtn.y and y < state._headerLyricsOnlyBtn.y + state._headerLyricsOnlyBtn.h then\n    state._modeSwOldBuf = state.solfegeInputBuffer or \"\"\n    enterLyricsScreen({commitInput = true, refreshBuffer = true})\n    state._modeSwOldBuf = nil\n    savePreferences()\n    return\nend\n\nif state._headerComposeModeBtn\n   and x >= state._headerComposeModeBtn.x and x < state._headerComposeModeBtn.x + state._headerComposeModeBtn.w\n   and y >= state._headerComposeModeBtn.y and y < state._headerComposeModeBtn.y + state._headerComposeModeBtn.h then\n    state._modeSwOldBuf = state.solfegeInputBuffer or \"\"\n    if state.solfegeInputActive then commitSolfegeInput() end\n    if state._lyricsForcesHideSteps then\n        state.hideSteps = state._lyricsHideStepsSaved and true or false\n        state._lyricsHideStepsSaved = nil\n        state._lyricsForcesHideSteps = nil\n    end\n    state.solfegeTextMode = \"both\"\n    state.solfegeInputBuffer = serializeSequenceToText()\n    state._solfegeSeqText = state.solfegeInputBuffer\n    if state.solfegeInputBuffer == \"\" and state._modeSwOldBuf ~= \"\" then\n        state.solfegeInputBuffer = state._modeSwOldBuf\n        state._solfegeSeqText = state.solfegeInputBuffer\n    end\n    state._modeSwOldBuf = nil\n    state.solfegeInputActive = false\n    state.showSolfegeTextInput = false\n    state.singSolfegeMode = false\n    state.templateStepsOnly = false\n    syncPitchRecognitionForSingSolfege()\n    savePreferences()\n    return\nend\n\nif state._headerSingModeBtn\n   and x >= state._headerSingModeBtn.x and x < state._headerSingModeBtn.x + state._headerSingModeBtn.w\n   and y >= state._headerSingModeBtn.y and y < state._headerSingModeBtn.y + state._headerSingModeBtn.h then\n    state._modeSwOldBuf = state.solfegeInputBuffer or \"\"\n    if state.solfegeInputActive then commitSolfegeInput() end\n    if state._lyricsForcesHideSteps then\n        state.hideSteps = state._lyricsHideStepsSaved and true or false\n        state._lyricsHideStepsSaved = nil\n        state._lyricsForcesHideSteps = nil\n    end\n    state.solfegeTextMode = \"both\"\n    state.solfegeInputBuffer = serializeSequenceToText()\n    state._solfegeSeqText = state.solfegeInputBuffer\n    if state.solfegeInputBuffer == \"\" and state._modeSwOldBuf ~= \"\" then\n        state.solfegeInputBuffer = state._modeSwOldBuf\n        state._solfegeSeqText = state.solfegeInputBuffer\n    end\n    state._modeSwOldBuf = nil\n    state.solfegeInputActive = false\n    state.showSolfegeTextInput = false\n    state.templateStepsOnly = false\n    if not state.singSolfegeMode then\n        resetSingSolfegeOctaveOffset()\n    end\n    state.singSolfegeMode = true\n    syncPitchRecognitionForSingSolfege()\n    savePreferences()\n    return\nend\n\n\n-- Command chat toggle button (>)\ndo\n    local ctb = state._cmdChatToggleBtn\n    if ctb and x >= ctb.x and x < ctb.x + ctb.w and y >= ctb.y and y < ctb.y + ctb.h then\n        state.cmdChatOpen = not state.cmdChatOpen\n        state.cmdChatInputActive = state.cmdChatOpen\n        if state.cmdChatOpen then state.cmdChatCursorResetTime = os.clock() end\n        if state.cmdChatOpen and #state.cmdChatMessages == 0 then\n            table.insert(state.cmdChatMessages, cmdChat.welcomeMessage())\n        end\n        return\n    end\nend\n\n-- Toolbar toggle button (≡)\ndo\n    local wcb = state._webcamWindowBtn\n    if wcb and x >= wcb.x and x < wcb.x + wcb.w and y >= wcb.y and y < wcb.y + wcb.h then\n        openWebcamWindow()\n        return\n    end\n\n    local ttb = state._toolbarToggleBtn\n    if ttb and x >= ttb.x and x < ttb.x + ttb.w and y >= ttb.y and y < ttb.y + ttb.h then\n        toggleDisplayOption(\"showToolsRow\")\n        return\n    end\nend\n\n    -- Row 2 button hit-tests across SCREEN_W (Edit mode only)\n    local row2Columns = 16\n    local _dynColW = math.floor((gfx.getScreenWidth and gfx.getScreenWidth() or 400) / row2Columns)\n    if state.showToolsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and not state.singSolfegeMode and y >= row2TopY and y < row2BottomY then\n        local colW = _dynColW\n        local col = math.min(row2Columns - 1, math.floor(x / colW))\n\n        if col == 0 then\n            -- Select column\n            state.multiSelectMode = not state.multiSelectMode\n            state.selectedSteps = {}\n            return\n        elseif col == 1 then\n            -- Copy column: copy selected steps to clipboard\n            if state.multiSelectMode and state.selectedSteps and next(state.selectedSteps) then\n                local sortedIndices = {}\n                for si in pairs(state.selectedSteps) do table.insert(sortedIndices, si) end\n                table.sort(sortedIndices)\n                state.clipboard = {}\n                for _, si in ipairs(sortedIndices) do\n                    if state.sequence[si] then\n                        table.insert(state.clipboard, deepCopyStep(state.sequence[si]))\n                    end\n                end\n            elseif state.sequence[state.currentStep] then\n                -- No selection: copy current step\n                state.clipboard = { deepCopyStep(state.sequence[state.currentStep]) }\n            end\n            return\n        elseif col == 2 then\n            -- Paste column: insert clipboard at current step position\n            if state.clipboard and #state.clipboard > 0 then\n                local seqIndex = state.activeSequenceIndex\n                local insertAt = state.currentStep or 1\n                recordStepHistoryIfNeeded()\n                for i, stepData in ipairs(state.clipboard) do\n                    core.insertStep(state, seqIndex, insertAt + i - 1, deepCopyStep(stepData))\n                end\n                markSequenceDirty()\n                -- Move current step to end of pasted region\n                state.currentStep = math.min(insertAt + #state.clipboard - 1, state.sequenceLength)\n            end\n            return\n        elseif col == 3 then\n            -- Rest column: convert selected steps (or current step) to rests\n            local seqIndex = state.activeSequenceIndex\n            if state.multiSelectMode and state.selectedSteps and next(state.selectedSteps) then\n                recordStepHistoryIfNeeded()\n                for si in pairs(state.selectedSteps) do\n                    core.setStepRest(state, seqIndex, si)\n                end\n                state.selectedSteps = {}\n                state.multiSelectMode = false\n            elseif state.currentStep then\n                local seqLen = math.max(state.sequenceLengths[seqIndex] or 0, state.sequenceLength or 0)\n                if state.currentStep == seqLen + 1 and seqLen < core.maxSteps then\n                    -- At \"add note\" cursor: extend sequence with a rest and advance\n                    state.sequenceLength = seqLen + 1\n                    state.sequenceLengths[seqIndex] = state.sequenceLength\n                    state.sequence[state.currentStep] = { note = 13, octave = 4 }\n                    state.currentStep = state.currentStep + 1\n                elseif state.currentStep >= 1 and state.currentStep <= seqLen then\n                    -- Within sequence bounds: set step to rest (even if slot is currently empty)\n                    core.setStepRest(state, seqIndex, state.currentStep)\n                end\n            end\n            markSequenceDirty()\n            return\n        elseif col == 4 then\n            -- Tie/glue column: merge all selected steps into one longer step\n            if state.multiSelectMode and state.selectedSteps and next(state.selectedSteps) then\n                local seqIndex = state.activeSequenceIndex\n                local sortedIdx = {}\n                for si in pairs(state.selectedSteps) do table.insert(sortedIdx, si) end\n                table.sort(sortedIdx)\n                if #sortedIdx > 1 then\n                    local firstIdx = sortedIdx[1]\n                    local lastIdx  = sortedIdx[#sortedIdx]\n                    local seq = state.sequences[seqIndex]\n                    -- Nil out all selected steps except the first (they become continuation cells)\n                    for i = 2, #sortedIdx do\n                        seq[sortedIdx[i]] = nil\n                    end\n                    -- Span first step from firstIdx to lastIdx\n                    if seq[firstIdx] then\n                        seq[firstIdx].length = lastIdx - firstIdx + 1\n                    end\n                end\n                state.selectedSteps = {}\n                state.multiSelectMode = false\n                markSequenceDirty()\n            end\n            return\n        elseif col == 5 then\n            -- Delete column\n            if state.multiSelectMode and state.selectedSteps and next(state.selectedSteps) then\n                local toDelete = {}\n                for si in pairs(state.selectedSteps) do table.insert(toDelete, si) end\n                table.sort(toDelete, function(a, b) return a > b end)\n                for _, si in ipairs(toDelete) do\n                    state.currentStep = si\n                    tryDeleteCurrentStep()\n                end\n                state.selectedSteps = {}\n                state.multiSelectMode = false\n            else\n                state.editMode = (state.editMode == \"delete\") and \"note\" or \"delete\"\n                state.muteMode = false  -- exit mute mode when toggling delete\n            end\n            return\n        elseif col == 6 then\n            -- Mute column\n            if state.multiSelectMode and state.selectedSteps and next(state.selectedSteps) then\n                -- Mute/unmute all selected steps: if any are unmuted, mute all; if all muted, unmute all\n                local allMuted = true\n                for si in pairs(state.selectedSteps) do\n                    if state.sequence[si] and not state.sequence[si].muted then\n                        allMuted = false; break\n                    end\n                end\n                for si in pairs(state.selectedSteps) do\n                    if state.sequence[si] then\n                        state.sequence[si].muted = not allMuted\n                    end\n                end\n                markSequenceDirty()\n            else\n                state.muteMode = not state.muteMode\n                state.editMode = \"note\"  -- exit delete mode if active\n                if state.muteMode then state.gateMode = false end\n            end\n            return\n        elseif col == 7 then\n            -- Gate mode column\n            state.gateMode = not state.gateMode\n            if state.gateMode then\n                state.muteMode = false\n                state.editMode = \"note\"  -- exit delete mode if active\n            end\n            return\n        elseif col == 8 then\n            shiftPatternOctave(-1)\n            return\n        elseif col == 9 then\n            shiftPatternOctave(1)\n            return\n        elseif col == 10 then\n            local step = math.max(1, math.min(core.maxSteps, (state.currentStep or 1)))\n            if step <= 1 then\n                state.rowBreakAfterStep = nil\n            else\n                state.rowBreakAfterStep = step - 1\n            end\n            return\n        elseif col == 11 then\n            local step = math.max(1, math.min(core.maxSteps, (state.currentStep or 1)))\n            local stepData = state.sequence[step]\n            if not stepData then\n                return\n            end\n            local lyric = stepData.lyric\n            if type(lyric) ~= \"string\" or lyric == \"\" then\n                return\n            end\n            if lyric:find(\"\\n\\n\", 1, true) then\n                local withoutParagraph = lyric:gsub(\"\\n\\n+\", \"\")\n                stepData.lyric = (withoutParagraph ~= \"\" and withoutParagraph) or nil\n            else\n                stepData.lyric = lyric:gsub(\"\\n+$\", \"\") .. \"\\n\\n\"\n            end\n            markSequenceDirty()\n            return\n        elseif col == 12 then\n            -- Loop range start: set or clear\n            local step = math.max(1, math.min(state.sequenceLength or 1, state.currentStep or 1))\n            if state.stepLoopStart == step then\n                state.stepLoopStart = nil\n                state.stepLoopEnd   = nil\n            else\n                state.stepLoopStart = step\n                if state.stepLoopEnd and state.stepLoopEnd < step then\n                    state.stepLoopEnd = step\n                end\n            end\n            return\n        elseif col == 13 then\n            -- Loop range end: set or clear\n            local step = math.max(1, math.min(state.sequenceLength or 1, state.currentStep or 1))\n            if state.stepLoopEnd == step then\n                state.stepLoopStart = nil\n                state.stepLoopEnd   = nil\n            else\n                state.stepLoopEnd = step\n                if state.stepLoopStart and state.stepLoopStart > step then\n                    state.stepLoopStart = step\n                end\n                if not state.stepLoopStart then\n                    state.stepLoopStart = 1\n                end\n            end\n            return\n        elseif col == 14 then\n            -- Undo\n            if state.solfegeInputActive then undoSolfegeTextEdit() else undoStepChange() end\n            return\n        elseif col == 15 then\n            -- Redo\n            if state.solfegeInputActive then redoSolfegeTextEdit() else redoStepChange() end\n            return\n        end\n    end\n\n\n    -- Left-click on Key sub-header area: focus and cycle key up; if already focused, play the key note\n    if inKey then\n        if state.keyFocused then\n            playKeyCenterPreview()\n        else\n            state.keyFocused = true\n            state.bpmFocused = false\n            adjustRootNote(1)\n            savePreferences()\n        end\n        return\n    end\n\n    -- Left-click on BPM sub-header area: start text editing + drag-to-scrub candidate\n    if inBpm then\n        bpmDrag.active = true\n        bpmDrag.startY = y\n        bpmDrag.startTempo = state.tempo\n        bpmDrag.moved = false\n        if not state.bpmEditing then\n            state.bpmEditing = true\n            state.bpmInputBuffer = tostring(state.tempo)\n            state.bpmInputCursor = #tostring(state.tempo)\n        else\n            local buf = state.bpmInputBuffer or \"\"\n            local positions = state._bpmCharXPositions\n            if positions and #buf > 0 then\n                local closest, closestDist = #buf, math.huge\n                for i, xPos in ipairs(positions) do\n                    local dist = math.abs(x - xPos)\n                    if dist < closestDist then closestDist = dist; closest = i - 1 end\n                end\n                state.bpmInputCursor = closest\n            end\n        end\n        state.bpmFocused = true\n        state.keyFocused = false\n        return\n    end\n\n    -- Click on sidebar bottom buttons (Mute)\n    local screenH = (gfx and gfx.getScreenHeight) and gfx.getScreenHeight() or 240\n    local sidebarEntryH = 15\n    local muteY  = screenH - sidebarEntryH - 2\n    if state.sidebarOpen and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and x < 60 and y >= muteY and y < muteY + sidebarEntryH then\n        state.audioMuted = not state.audioMuted\n        setMasterVolume(state.masterVolume or 1, false)\n        savePreferences()\n        return\n    end\n\n    -- Click on sidebar sequence entries\n    local sidebarStartY = state.singSolfegeMode and 70 or 72\n    if state.sidebarOpen and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and x < 60 and y >= sidebarStartY then\n        local entryIndex = math.floor((y - sidebarStartY) / 13)\n        -- Map entry index to actual sequence index (active + non-empty sequences)\n        local visibleIndex = 0\n        for i = 1, core.maxSequences do\n            local hasContent = state.sequences[i] and (state.sequenceLengths[i] or 0) > 0\n            if hasContent or i == state.activeSequenceIndex then\n                if visibleIndex == entryIndex then\n                    setActiveSequence(i)\n                    markSequenceDirty()\n                    return\n                end\n                visibleIndex = visibleIndex + 1\n            end\n        end\n    end\n\n    -- Lyric notes side panel interactions\n    do\n        if handleLyricNotesPanelMouseDown(x, y, button, gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240) then return end\n    end\n\n    -- Check for click in the lyric strip.\n    -- Clicking directly on a rendered lyric word opens the syllable picker so the\n    -- note associated with that word can be added/changed in one tap.\n    -- Clicking elsewhere in the strip keeps the existing lyric-text editing flow.\n    do\n        local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n        if grid and grid.lyricRowH and grid.lyricRowH > 0 and not state.isPlaying then\n            local showStepGridNow = ((state.solfegeTextMode or \"both\") ~= \"lyrics\") and (not state.hideSteps or state.useShapeNotes)\n            local spanX = grid.stepWidth + grid.horizontalGap\n            local spanY = grid.stepHeight + grid.verticalGap\n            local _sc = grid.gridScrollCol or 0\n            local col = math.floor((x - grid.startX) / spanX) + _sc\n            local row = math.floor((y - grid.startY) / spanY)\n            if col >= _sc and col < _sc + (grid.visibleCols or grid.stepsPerRow) and row >= 0 and row < grid.rowsToShow then\n                local cellY = grid.startY + row * spanY\n                -- When steps visible: lyric strip is below the cell; when hidden: lyric is inside the cell area\n                local lyricZoneTop, lyricZoneBot\n                if showStepGridNow then\n                    lyricZoneTop = cellY + grid.stepHeight\n                    lyricZoneBot = lyricZoneTop + grid.lyricRowH\n                else\n                    lyricZoneTop = cellY\n                    lyricZoneBot = cellY + grid.stepHeight\n                end\n                if y >= lyricZoneTop and y < lyricZoneBot then\n                    local stepIdx = row * grid.stepsPerRow + col + 1\n                    if stepIdx >= 1 and stepIdx <= core.maxSteps then\n                        local step = state.sequence[stepIdx]\n                        local lyricText = step and step.lyric or nil\n                        local lyricY = showStepGridNow and (cellY + grid.stepHeight + 1) or (cellY + 1)\n                        local lyricX = grid.startX + (col - _sc) * spanX + 1\n                        local lyricH = 10\n                        local stepLyricBuffer = ((step and step.lyric) or \"\"):gsub(\"\\n+$\", \"\")\n                        if type(lyricText) == \"string\" and lyricText ~= \"\" and state.lyricEditingStepIndex ~= stepIdx then\n                            local lyricW = gfx.getTextSize(lyricText)\n                            if x >= lyricX and x < lyricX + lyricW and y >= lyricY and y < lyricY + lyricH then\n                                lyricTokenDrag.active = true\n                                lyricTokenDrag.token = lyricText\n                                lyricTokenDrag.x = x\n                                lyricTokenDrag.y = y\n                                lyricTokenDrag.startX = x\n                                lyricTokenDrag.startY = y\n                                lyricTokenDrag.moved = false\n                                lyricTokenDrag.source = \"timeline\"\n                                lyricTokenDrag.stepIndex = stepIdx\n                                return\n                            end\n                        end\n\n                        -- Use \"small\" font for measurement (matches the render font).\n                        if ui and ui.setMeasureFont then ui.setMeasureFont(\"small\") end\n                        local clickedPos = textEditorPosFromSingleLine(stepLyricBuffer, x - lyricX)\n                        local newCur = clickedPos >= #stepLyricBuffer and nil or clickedPos\n                        local posNum = (newCur ~= nil) and newCur or #stepLyricBuffer\n                        local sameEditor = state.lyricEditingStepIndex == stepIdx\n                        state.lyricEditingStepIndex = stepIdx\n                        state.lyricInputBuffer = stepLyricBuffer\n                        state.lyricInputCursor = newCur\n                        state.lyricNotesInputActive = false\n                        if button == 3 then\n                            state.lyricSelAnchor = posNum\n                            state.lyricSelFocus = posNum\n                            showLyricStepCtxMenu(x, y, gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240)\n                        else\n                            -- Double-click word selection\n                            local _now2 = getCurrentTimeMilliseconds and getCurrentTimeMilliseconds() or (os.clock() * 1000)\n                            local _lct2 = state._lyricStepLastClickTime or 0\n                            local _lcx2 = state._lyricStepLastClickX or -999\n                            local _lcy2 = state._lyricStepLastClickY or -999\n                            local _dx2, _dy2 = x - _lcx2, y - _lcy2\n                            local _isDbl2 = (not _shiftHeld)\n                                and (_now2 - _lct2 < DOUBLE_CLICK_MS)\n                                and (_dx2 * _dx2 + _dy2 * _dy2 <= DOUBLE_CLICK_RADIUS * DOUBLE_CLICK_RADIUS)\n                            state._lyricStepLastClickTime = _now2\n                            state._lyricStepLastClickX = x\n                            state._lyricStepLastClickY = y\n                            if _isDbl2 then\n                                local ws2, we2 = findWordBoundsAt0(stepLyricBuffer, clickedPos)\n                                if ws2 and we2 then\n                                    state.lyricInputCursor = (we2 >= #stepLyricBuffer) and nil or we2\n                                    state.lyricSelAnchor = ws2\n                                    state.lyricSelFocus  = we2\n                                    state._lyricStepDragSel = false\n                                    return\n                                end\n                            end\n                            if _shiftHeld and sameEditor and state.lyricSelAnchor ~= nil then\n                                state.lyricSelFocus = posNum\n                            else\n                                state.lyricSelAnchor = posNum\n                                state.lyricSelFocus = posNum\n                            end\n                            state._lyricStepDragSel = true\n                        end\n                        return\n                    end\n                end\n            end\n        end\n    end\n\n    -- Click on the plus button after the last step to extend sequence length.\n    do\n        local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n        local addBtn = grid and grid.addStepButton\nif button == 1 and ((state.solfegeTextMode or \"both\") ~= \"lyrics\") and (not state.hideSteps or state.useShapeNotes) and addBtn then\n            if x >= addBtn.x and x < addBtn.x + addBtn.w and y >= addBtn.y and y < addBtn.y + addBtn.h then\n                breakEditCursorFollow()\n                -- Use trimmed display length as the base so + always adds right after the last real note\n                local baseLen = (grid.displaySequenceLength or 0)\n                if baseLen < core.maxSteps then\n                    -- Truncate any trailing rests/empty steps beyond the trim point\n                    for i = baseLen + 1, (state.sequenceLength or 0) do\n                        state.sequence[i] = nil\n                    end\n                    local newLen = baseLen + 1\n                    state.sequenceLength = newLen\n                    state.sequenceLengths[state.activeSequenceIndex] = newLen\n                    state.sequence[newLen] = { note = 13, octave = 4 }\n                    markSequenceDirty()\n                    state.currentStep = newLen\n                    state.selectedNote = 13\n                    state.currentOctave = 4\n                end\n                return\n            end\n        end\n    end\n\n    -- Mouse-down on a step: zone-based — edges = stretch, middle = hold-to-reorder\n    -- While playing, only allow selection (no stretch/reorder)\n    local si, sc, sr = stepIndexAtPoint(x, y)\n    if si and not state.sequence[si] and not state.multiSelectMode then\n        -- Empty step: set hold-pending so a tap opens the syllable dropdown to add a note\n        holdPending.active = true\n        holdPending.stepIndex = si\n        holdPending.startTime = os.clock()\n        holdPending.startX = x\n        holdPending.startY = y\n        return\n    end\n    if si and state.sequence[si] then\n        -- In multiSelectMode: track mousedown for potential drag or tap\n        if state.multiSelectMode then\n            -- Save snapshot of current selection; don't clear yet to avoid flash\n            selectDrag.savedSelection = {}\n            for k, v in pairs(state.selectedSteps or {}) do\n                selectDrag.savedSelection[k] = v\n            end\n            selectDrag.startIndex = si\n            selectDrag.startX = x\n            selectDrag.startY = y\n            selectDrag.active = false  -- promoted to active on move\n            return\n        end\n        if not state.isPlaying then\n            local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n            local inEdgeZone = false\n            if grid then\n                local xOff = grid.xOffsets and grid.xOffsets[si] or 0\n                local cellX = grid.startX + (sc - (grid.gridScrollCol or 0)) * (grid.stepWidth + grid.horizontalGap) - xOff\n                local relX = x - cellX\n                -- Compute actual rendered width (accounts for sub-step and stretched steps)\n                local stepLen = core.getStepLength(state.sequence[si])\n                local maxLenInRow = grid.stepsPerRow - sc\n                local actualW\n                if stepLen < 1.0 then\n                    actualW = math.max(4, math.floor(grid.stepWidth * stepLen))\n                else\n                    local clampedLen = math.min(math.ceil(stepLen), maxLenInRow)\n                    actualW = grid.stepWidth * clampedLen + grid.horizontalGap * (clampedLen - 1)\n                end\n                local edgeW = math.max(6, math.floor(grid.stepWidth * 0.35))\n                local canUseEdgeStretch = actualW >= math.max(14, edgeW * 2 + 2)\n                local onLeftEdge = canUseEdgeStretch and relX < edgeW\n                local onRightEdge = canUseEdgeStretch and relX >= actualW - edgeW\n                inEdgeZone = onLeftEdge or onRightEdge\n                if inEdgeZone then\n                    stretchDrag.rightEdge = onRightEdge\n                end\n            end\n            if inEdgeZone then\n                -- Edge zone: immediately start stretch\n                stretchDrag.active = true\n                stretchDrag.stepIndex = si\n                stretchDrag.startCol = sc\n                stretchDrag.startRow = sr\n                stretchDrag.startMouseX = x\n                stretchDrag.startLen = core.getStepLength(state.sequence[si])\n                stretchDrag.previewLen = stretchDrag.startLen\n            elseif state.gateMode then\n                -- Gate mode: drag vertically to set gate value\n                gateDrag.active = true\n                gateDrag.stepIndex = si\n                gateDrag.startMouseY = y\n                gateDrag.startGate = state.sequence[si].gate or 0.9\n                gateDrag.moved = false\n            else\n                -- Middle zone: start hold-pending; becomes reorder only if mouse moves enough\n                holdPending.active = true\n                holdPending.stepIndex = si\n                holdPending.startTime = os.clock()\n                holdPending.startX = x\n                holdPending.startY = y\n            end\n        else\n            -- While playing: just start hold-pending for selection only (no reorder/stretch)\n            holdPending.active = true\n            holdPending.stepIndex = si\n            holdPending.startTime = os.clock()\n            holdPending.startX = x\n            holdPending.startY = y\n        end\n        return  -- consume click\n    end\n    end\n\n    -- MOUSE move handler (SDL desktop only) — updates drag-to-stretch and drag-to-reorder\n    callbacks.onMouseMove = function(x, y, sourceWindow)\n    if sourceWindow == \"options\" then\n        state._optWinMouseX = x; state._optWinMouseY = y\n        return\n    end\n    if state._cmdChatDraggingResize then\n        local dy = (state._cmdChatDragStartY or y) - y\n        local newH = math.max(40, math.min(math.floor(((SCREEN_H or 300) * 0.6)), (state._cmdChatDragStartH or 60) + dy))\n        state.cmdChatBottomH = newH\n        return\n    end\n    if state._cmdChatDragSel and state.cmdChatInputActive then\n        local pos = cmdChatTextPosFromPoint(x, y)\n        state.cmdChatInputCursor = pos\n        state.cmdChatSelFocus = pos\n        state.cmdChatCursorResetTime = os.clock()\n        return\n    end\n    if sourceWindow == \"text\" then\n        -- Track mouse position inside secondary window for hover effects\n        state._textWindowMouseX = x\n        state._textWindowMouseY = y\n        local td = state._solfegeTextDragMove\n        if td and td.active then\n            local dx = x - (td.startX or x)\n            local dy = y - (td.startY or y)\n            if not td.moved and (dx * dx + dy * dy) >= (DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) then\n                td.moved = true\n            end\n            local dropPos = solfegeTextPosFromPoint(x, y)\n            if dropPos then td.dropPos = dropPos end\n            return\n        end\n        if state._solfegeDragSel and state.solfegeInputActive then\n            local ib = state._solfegeInputBounds\n            if ib then\n                -- Clamp coords so dragging outside still extends selection to start/end\n                local cx = math.max(ib.x, math.min(ib.x + ib.w - 1, x))\n                local cy = math.max(ib.y, math.min(ib.y + ib.h - 1, y))\n                state._pendingSolfegeClick = {x = cx, y = cy, isSelExtend = true}\n                state._solfegeDragRawY = y  -- raw y for auto-scroll direction detection\n            end\n        end\n        return\n    end\n    if welcomeScrollDrag.active then\n        local sb = state._welcomeScrollbar\n        if sb then\n            local newThumbY = y - welcomeScrollDrag.dragOffsetY\n            local trackTop = sb.trackTop\n            local clampedY = math.max(trackTop, math.min(trackTop + sb.availH - sb.thumbH, newThumbY))\n            local maxFirst = math.max(1, sb.total - sb.visibleCount + 1)\n            local ratio = (clampedY - trackTop) / math.max(1, sb.availH - sb.thumbH)\n            local first = 1 + math.floor(ratio * (sb.total - sb.visibleCount) + 0.5)\n            state._welcomeFirstVisible = math.max(1, math.min(maxFirst, first))\n        end\n        return\n    end\n    if lyricTokenDrag.active then\n        lyricTokenDrag.x = x\n        lyricTokenDrag.y = y\n        local startX = lyricTokenDrag.startX or x\n        local startY = lyricTokenDrag.startY or y\n        local dx = x - startX\n        local dy = y - startY\n        if (dx * dx + dy * dy) >= (DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) then\n            lyricTokenDrag.moved = true\n        end\n        return\n    end\n    if bpmDrag.active then\n        local dy = y - (bpmDrag.startY or y)\n        if math.abs(dy) >= 2 then\n            bpmDrag.moved = true\n            -- Once dragging, cancel text editing and switch to scrub mode\n            state.bpmEditing = false\n            state.bpmInputBuffer = nil\n            -- drag up (negative dy) = increase; 2px per 1 BPM\n            local newTempo = (bpmDrag.startTempo or state.tempo) - math.floor(dy / 2)\n            setTempo(newTempo)\n        end\n        state.mouseX = x\n        state.mouseY = y\n        return\n    end\n    state.mouseX = x\n    state.mouseY = y\n\n    if state._allOptionsDragActive then\n        state._allOptionsPanelX = x - (state._allOptionsDragOX or 0)\n        state._allOptionsPanelY = y - (state._allOptionsDragOY or 0)\n        return\n    end\n\n    local macTopRowH = isRunningOnMacDesktop() and 24 or 0\n    local row2TopY = macTopRowH + 28 + (state._macFilenameRowH or 0)\n    local row2BottomY = row2TopY + (((state.showToolsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\") and 28) or 0)\n    local subHeaderTopY = row2BottomY\n    local subHeaderBottomY = subHeaderTopY + (((state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\") and 24) or 0)\n\n    if lnSplitDrag.active then\n        local dx = x - (lnSplitDrag.startX or x)\n        local tw = lnSplitDrag.totalW\n        if tw and tw > 0 then\n            local newRatio = lnSplitDrag.startRatio + dx / tw\n            state.lnSplitRatio = math.max(0.2, math.min(0.65, newRatio))\n        end\n        state.mouseX = x\n        state.mouseY = y\n        return\n    end\n    if solfegeBottomDrag.active then\n        local dy = y - (solfegeBottomDrag.startY or y)\n        local newH = (solfegeBottomDrag.startH or 60) - dy\n        state.solfegeBottomH = math.max(40, math.min(math.floor((SCREEN_H or 300) / 2), math.floor(newH)))\n        state.mouseX = x\n        state.mouseY = y\n        return\n    end\n    if solfegeDrag.active then\n        local dx = x - (solfegeDrag.startX or x)\n        local newW\n        if solfegeDrag.side == \"left\" then\n            newW = (solfegeDrag.startWidth or 130) + dx\n        else\n            newW = (solfegeDrag.startWidth or 130) - dx\n        end\n        state.solfegeInputWidth = math.max(80, math.min(260, math.floor(newW)))\n        state.mouseX = x\n        state.mouseY = y\n        return\n    end\n    if solfegeFloatDrag.active then\n        state.solfegeFloatX = math.max(0, math.min(SCREEN_W - 100, solfegeFloatDrag.startPanelX + (x - solfegeFloatDrag.startX)))\n        state.solfegeFloatY = math.max(0, math.min(SCREEN_H - 40, solfegeFloatDrag.startPanelY + (y - solfegeFloatDrag.startY)))\n        state.mouseX = x\n        state.mouseY = y\n        return\n    end\n    if solfegeFloatResize.active then\n        state.solfegeFloatW = math.max(140, math.min(400, solfegeFloatResize.startW + (x - solfegeFloatResize.startX)))\n        state.solfegeFloatH = math.max(80, solfegeFloatResize.startH + (y - solfegeFloatResize.startY))\n        state.mouseX = x\n        state.mouseY = y\n        return\n    end\n    if volumeSliderDrag.active and isRunningOnMacDesktop() then\n        local bounds = state._masterVolumeSliderBounds\n        if bounds then\n            local sliderWidth = math.max(1, bounds.width - 1)\n            local normalized = (x - bounds.x) / sliderWidth\n            setMasterVolume(normalized, false)\n        end\n        return\n    end\n    local td = state._solfegeTextDragMove\n    if td and td.active then\n        local dx = x - (td.startX or x)\n        local dy = y - (td.startY or y)\n        if not td.moved and (dx * dx + dy * dy) >= (DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) then\n            td.moved = true\n        end\n        local dropPos = solfegeTextPosFromPoint(x, y)\n        if dropPos then td.dropPos = dropPos end\n        return\n    end\n\n    -- Drag-to-select in the solfege text box\n    if state._solfegeDragSel and state.solfegeInputActive then\n        local ib = state._solfegeInputBounds\n        if ib then\n            -- Clamp coords so dragging outside still extends selection to start/end\n            local cx = math.max(ib.x, math.min(ib.x + ib.w - 1, x))\n            local cy = math.max(ib.y, math.min(ib.y + ib.h - 1, y))\n            state._pendingSolfegeClick = {x = cx, y = cy, isSelExtend = true}\n            state._solfegeDragRawY = y  -- raw y for auto-scroll direction detection\n        end\n    end\n\n    -- Drag-to-select in the lyric notes text box\n    if state._lyricNotesDragSel and state.lyricNotesInputActive then\n        local dragPos = lyricNotesTextPosFromPoint(x, y)\n        if dragPos ~= nil then\n            local buf = state.lyricNotesBuffer or \"\"\n            local newCur = dragPos >= #buf and nil or dragPos\n            local posNum = (newCur ~= nil) and newCur or #buf\n            state.lyricNotesCursor = newCur\n            state.lyricNotesSelFocus = posNum\n        end\n    end\n\n    if state._lyricStepDragSel and state.lyricEditingStepIndex then\n        local dragPos = lyricStepTextPosFromPoint(x, y)\n        if dragPos ~= nil then\n            local buf = state.lyricInputBuffer or \"\"\n            local newCur = dragPos >= #buf and nil or dragPos\n            local posNum = (newCur ~= nil) and newCur or #buf\n            state.lyricInputCursor = newCur\n            state.lyricSelFocus = posNum\n        end\n    end\n\n    -- Tooltip detection\n    local tooltipCandidate = nil\n    state.tooltip = nil\n    if not state.singSolfegeMode and not state.showModeSelect then\n        if (state.solfegeTextMode or \"both\") ~= \"lyrics\" and y >= row2TopY and y < row2BottomY then\n            local tips = {\n                [0]=\"Sel — toggle multi-select (Shift+click to extend)\",\n                [1]=\"Cpy — copy selected steps (Cmd+C)\",\n                [2]=\"Pst — paste at current step (Cmd+V)\",\n                [3]=\"Rst — convert selected steps to rests\",\n                [4]=\"Tie — glue selected steps together\",\n                [5]=\"Del — delete mode: click any step to remove it (Cmd+Z to undo)\",\n                [6]=\"Mut — mute mode: click any step to toggle mute\",\n                [7]=\"Gat — gate mode: drag step edge to change gate length\",\n                [8]=\"Oct- — shift selected steps one octave down\",\n                [9]=\"Oct+ — shift selected steps one octave up\",\n                [10]=\"Row break — insert line break after current step\",\n                [11]=\"Para — end paragraph here\",\n                [12]=\"[ — set loop start at current step\",\n                [13]=\"] — set loop end at current step\",\n                [14]=\"Und — undo last edit (Cmd+Z)\",\n                [15]=\"Red — redo last undone edit (Cmd+Shift+Z)\",\n            }\n            local row2Columns = 16\n            local colW = math.floor((gfx.getScreenWidth and gfx.getScreenWidth() or 400) / row2Columns)\n            local col  = math.min(row2Columns - 1, math.floor(x / colW))\n            if tips[col] then\n                tooltipCandidate = { text = tips[col], anchorY = row2BottomY + 1 }\n            end\n        elseif y >= subHeaderTopY and y < subHeaderBottomY then\n            local function inR(rx, rw) return rx and rw and rw > 0 and x >= (rx-4) and x < (rx+rw+4) end\n            if inR(state._seqLenHeaderX, state._seqLenHeaderW) then\n                tooltipCandidate = { text = state._stepsPerBar and \"Length (bars)\" or \"Length (steps)\", anchorY = subHeaderBottomY + 1 }\n            elseif inR(state._beatsHeaderX, state._beatsHeaderW) then\n                tooltipCandidate = { text = \"Length (beats)\", anchorY = subHeaderBottomY + 1 }\n            elseif inR(state._stepDurHeaderX, state._stepDurHeaderW) then\n                tooltipCandidate = { text = \"Step duration\", anchorY = subHeaderBottomY + 1 }\n            elseif inR(state._keyHeaderX, state._keyHeaderW) then\n                tooltipCandidate = { text = \"Root key\", anchorY = subHeaderBottomY + 1 }\n            elseif inR(state._loopHeaderX, state._loopHeaderW) then\n                tooltipCandidate = { text = \"Loop playback\", anchorY = subHeaderBottomY + 1 }\n            elseif inR(state._meterHeaderX, state._meterHeaderW) then\n                tooltipCandidate = { text = \"Time sig\", anchorY = subHeaderBottomY + 1 }\n            elseif inR(state._bpmHeaderX, state._bpmHeaderW) then\n                tooltipCandidate = { text = \"Tempo\", anchorY = subHeaderBottomY + 1 }\n            end\n        end\n    end\n\n    -- Toolbar toggle button tooltip\n    do\n        local wcb = state._webcamWindowBtn\n        if wcb and x >= wcb.x and x < wcb.x + wcb.w and y >= wcb.y and y < wcb.y + wcb.h then\n            tooltipCandidate = {text = \"Open webcam\", anchorY = wcb.y + wcb.h + 2}\n        end\n\n        local ttb2 = state._toolbarToggleBtn\n        if tooltipCandidate == nil and ttb2 and x >= ttb2.x and x < ttb2.x + ttb2.w and y >= ttb2.y and y < ttb2.y + ttb2.h then\n            tooltipCandidate = {text = state.showToolsRow and \"Hide toolbar\" or \"Show toolbar\", anchorY = ttb2.y + ttb2.h + 2}\n        end\n    end\n\n    -- Text box button tooltips\n    if state.tooltip == nil then\n        local function inBtn(b) return b and x >= b.x and y >= b.y and x < b.x + b.w and y < b.y + b.h end\n        local _refBtn = state._solfegeParagraphBtn or state._solfegeBreakToggleBtn\n        local tipAnchorY = _refBtn and (_refBtn.y - 15) or (y - 15)\n        local mbtns = state._solfegeModeBtns\n        if mbtns then\n            for _, mb in ipairs(mbtns) do\n                if inBtn(mb) then\n                    local labels = {steps=\"Steps only\", lyrics=\"Lyrics only\", both=\"Steps + Lyrics\"}\n                    tooltipCandidate = {text = labels[mb.mode] or mb.mode, anchorY = tipAnchorY}\n                    break\n                end\n            end\n        end\n        if state.tooltip == nil then\n            local sbtn = state._solfegeSizeBtn\n            if inBtn(sbtn) then\n                tooltipCandidate = {text = \"Text size: \" .. (sbtn.name or sbtn.label or \"Medium\"), anchorY = tipAnchorY}\n            else\n                local smi = state._solfegeSizeMenuItems\n                if smi then\n                    for _, item in ipairs(smi) do\n                        if inBtn(item) then\n                            tooltipCandidate = {text = \"Set text size: \" .. (item.name or item.label or \"Medium\"), anchorY = tipAnchorY}\n                            break\n                        end\n                    end\n                end\n            end\n        end\n        if state.tooltip == nil and inBtn(state._solfegeParagraphBtn) then\n            tooltipCandidate = {text = \"Insert paragraph break\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegeCopyBtn) then\n            tooltipCandidate = {text = \"Copy selection\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegeCutBtn) then\n            tooltipCandidate = {text = \"Cut selection\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegePasteBtn) then\n            tooltipCandidate = {text = \"Paste clipboard\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegeBreakToggleBtn) then\n            local on = (state.solfegeShowBreaks ~= false)\n            tooltipCandidate = {text = on and \"Hide break markers\" or \"Show break markers\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegeSpellBtn) then\n            local on = (state.solfegeSpellCheck == true)\n            tooltipCandidate = {text = on and \"Spell check: on\" or \"Spell check: off\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegeTextOnlyBtn) then\n            local on = (state.solfegeTextOnlyMode == true)\n            tooltipCandidate = {text = on and \"Text-only mode: on (hide grid)\" or \"Text-only mode: expand text box\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegeTemplateBtn) then\n            tooltipCandidate = {text = \"Insert scale/pattern template\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegelyricNotesPanelBtn) then\n            local on = (state.lyricNotesPanelOpen == true)\n            tooltipCandidate = {text = on and \"Lyric Notes: on\" or \"Open Lyric Notes import\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil and inBtn(state._solfegeLyricsWindowBtn) then\n            tooltipCandidate = {text = \"Open lyrics in window\", anchorY = tipAnchorY}\n        end\n        if state.tooltip == nil then\n            local dbtns = state._solfegeDockBtns\n            if dbtns then\n                local dockLabels = {left=\"Dock left\", bottom=\"Dock bottom\", right=\"Dock right\", float=\"Float\", window=\"Open in window\"}\n                for _, db in ipairs(dbtns) do\n                    if inBtn(db) then\n                        tooltipCandidate = {text = dockLabels[db.side] or db.side, anchorY = tipAnchorY}\n                        break\n                    end\n                end\n            end\n        end\n    end\n\n    -- Template browser: \"Steps only\" toggle tooltip\n    if state.showingTemplateBrowser and not tooltipCandidate then\n        local _scrH = gfx and gfx.getScreenHeight and gfx.getScreenHeight() or 240\n        local _scrW = gfx and gfx.getScreenWidth and gfx.getScreenWidth() or 400\n        local tW, tH = 90, 16\n        local tX = _scrW - tW - 6\n        local tY = _scrH - 36 + 5\n        if x >= tX and x < tX + tW and y >= tY and y < tY + tH then\n            tooltipCandidate = {text = \"Load notes only — skip tempo, key, and other settings\", anchorY = tY - 14}\n        end\n    end\n\n    do\n        local now = os.clock and os.clock() or 0\n        if tooltipCandidate then\n            local hoverKey = tostring(tooltipCandidate.text) .. \"@\" .. tostring(tooltipCandidate.anchorY)\n            if state._tooltipHoverKey ~= hoverKey then\n                state._tooltipHoverKey = hoverKey\n                state._tooltipHoverStart = now\n            end\n            -- Fast mode: if a tooltip was visible recently, next one appears quickly\n            local recentlyShown = (state._tooltipLastActiveAt or 0) > now - 0.6\n            local delay = recentlyShown and 0.03 or 0.25\n            if now - (state._tooltipHoverStart or now) >= delay then\n                state.tooltip = tooltipCandidate\n                state._tooltipLastActiveAt = now\n            end\n        else\n            state._tooltipHoverKey = nil\n            state._tooltipHoverStart = nil\n            -- _tooltipLastActiveAt decays naturally; don't clear it here\n        end\n    end\n\n    -- Drag-to-select: promote on movement, then update range\n    if selectDrag.startIndex and state.multiSelectMode then\n        local dx = x - (selectDrag.startX or x)\n        local dy = y - (selectDrag.startY or y)\n        if not selectDrag.active and math.sqrt(dx*dx + dy*dy) >= DRAG_THRESHOLD_PX then\n            selectDrag.active = true\n            -- selection was already cleared on mouseDown; nothing to do here\n        end\n        if selectDrag.active then\n            local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n            if grid then\n                local _sc = grid.gridScrollCol or 0\n                local col = math.floor((x - grid.startX) / (grid.stepWidth + grid.horizontalGap)) + _sc\n                local row = math.floor((y - grid.startY) / (grid.stepHeight + grid.verticalGap))\n                col = math.max(0, math.min(grid.stepsPerRow - 1, col))\n                row = math.max(0, math.min(grid.rowsToShow - 1, row))\n                local curIdx = row * grid.stepsPerRow + col + 1\n                curIdx = math.max(1, math.min(state.sequenceLength or 1, curIdx))\n                local lo = math.min(selectDrag.startIndex, curIdx)\n                local hi = math.max(selectDrag.startIndex, curIdx)\n                -- Start from saved selection so previous selection stays visible during drag\n                state.selectedSteps = {}\n                for k, v in pairs(selectDrag.savedSelection or {}) do\n                    state.selectedSteps[k] = v\n                end\n                for i = lo, hi do\n                    if state.sequence[i] then state.selectedSteps[i] = true end\n                end\n            end\n            return\n        end\n    end\n    -- Promote hold-pending to reorder if mouse moves far enough (not while playing)\n    if holdPending.active and not state.isPlaying then\n        local dx = x - (holdPending.startX or x)\n        local dy = y - (holdPending.startY or y)\n        if math.sqrt(dx*dx + dy*dy) >= DRAG_THRESHOLD_PX then\n            holdPending.active = false\n            reorderDrag.active = true\n            reorderDrag.stepIndex = holdPending.stepIndex\n            reorderDrag.targetIndex = holdPending.stepIndex\n        end\n    end\n    -- Update reorder drag target\n    if reorderDrag.active then\n        local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n        if grid then\n            -- Find nearest slot index based on cursor position\n            local _sc = grid.gridScrollCol or 0\n            local col = math.floor((x - grid.startX + (grid.stepWidth + grid.horizontalGap) / 2) / (grid.stepWidth + grid.horizontalGap)) + _sc\n            local row = math.floor((y - grid.startY) / (grid.stepHeight + grid.verticalGap))\n            row = math.max(0, math.min(grid.rowsToShow - 1, row))\n            col = math.max(0, math.min(grid.stepsPerRow, col))\n            local idx = row * grid.stepsPerRow + col + 1\n            -- Clamp to valid insert positions (1..sequenceLength+1, but not past maxSteps)\n            local seqLen = state.sequenceLength or 0\n            idx = math.max(1, math.min(seqLen + 1, idx))\n            idx = math.min(idx, core.maxSteps)\n            reorderDrag.targetIndex = idx\n        end\n        return\n    end\n    -- Update gate drag\n    if gateDrag.active then\n        local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n        if grid then\n            local dy = (gateDrag.startMouseY or y) - y  -- drag up = increase gate\n            if math.abs(dy) >= 2 then gateDrag.moved = true end\n            local newGate = math.max(0.05, math.min(1.0, (gateDrag.startGate or 0.9) + dy / (grid.stepHeight * 2)))\n            local step = state.sequence[gateDrag.stepIndex]\n            if step then\n                step.gate = newGate\n                markSequenceDirty()\n            end\n        end\n        return\n    end\n    if not stretchDrag.active then return end\n    -- Logarithmic scale: 40px = one doubling/halving.\n    local deltaPixels = x - (stretchDrag.startMouseX or x)\n    local sign = stretchDrag.rightEdge and 1 or -1\n    local rawLen = (stretchDrag.startLen or 1) * (2 ^ (sign * deltaPixels / 40))\n    local grid = ui.getStepGridLayout and ui.getStepGridLayout()\n    local maxLen = grid and math.max(1, core.maxSteps - (stretchDrag.stepIndex or 1) + 1) or 64\n    local newLen = math.max(0.125, math.min(maxLen, rawLen))\n    stretchDrag.previewLen = newLen\n    local step = state.sequence[stretchDrag.stepIndex]\n    if step and math.abs((step.length or 1) - newLen) > 0.0005 then\n        if core.stretchStep(state, stretchDrag.stepIndex, newLen, true) then\n            markSequenceDirty()\n        end\n    end\n    end\n\n    -- MOUSE up handler (SDL desktop only) — ends drag-to-stretch or drag-to-reorder\n    callbacks.onMouseUp = function(x, y, button, sourceWindow)\n    -- Solfege syllable button release: stop note, insert with held duration\n    if state._syllableBtnHeld then\n        local held = state._syllableBtnHeld\n        state._syllableBtnHeld = nil\n        state._syllableAddedAt = os.clock()\n        stopVoice(primaryVoice)\n        local dt = os.clock() - held.t\n        local stepDur = (state.stepDuration or 500) / 1000\n        local rawLen = math.max(0.25, dt / stepDur)\n        local stepLen = math.floor(rawLen * 4 + 0.5) / 4\n        if stepLen < 0.25 then stepLen = 0.25 end\n        local durSuffix = {[0.25]=\"/16\", [0.5]=\"/8\", [0.75]=\"/d8\", [1]=\"/4\", [1.5]=\"/d4\", [2]=\"/2\", [3]=\"/d2\", [4]=\"/1\", [6]=\"/d1\", [8]=\"/1\"}\n        local suffix = durSuffix[stepLen]\n        if not suffix then\n            local beats = stepLen * 4\n            suffix = \"/\" .. string.format(\"%.0f\", beats)\n        end\n        if state.showSolfegeTextInput ~= false then\n            insertSolfegeTemplateText(held.label .. suffix)\n        end\n        local displayNames = {[0.25]=\"/16\", [0.5]=\"/8\", [0.75]=\"/8.\", [1]=\"/4\", [1.5]=\"/4.\", [2]=\"/2\", [3]=\"/2.\", [4]=\"/1\", [6]=\"/1.\", [8]=\"/1..\"}\n        local lenStr = displayNames[stepLen] or suffix\n        table.insert(state.cmdChatMessages, {role = \"system\", text = held.label .. \" \" .. lenStr})\n        while #state.cmdChatMessages > 100 do table.remove(state.cmdChatMessages, 1) end\n        state.cmdChatScrollOffset = 0\n        return\n    end\n    if sourceWindow == \"text\" then\n        local td = state._solfegeTextDragMove\n        if td and td.active then\n            state._solfegeTextDragMove = nil\n            if td.moved and td.dropPos then\n                moveSelectedSolfegeText(td.dropPos)\n            end\n        end\n        -- End any solfege sidebar resize drag started from secondary window\n        if solfegeDrag.active then\n            solfegeDrag.active = false; solfegeDrag.startX = nil\n            solfegeDrag.startWidth = nil; solfegeDrag.side = nil\n            savePreferences()\n        end\n        if state._solfegeDragSel then state._solfegeDragSel = false end\n        if state._lyricNotesDragSel then state._lyricNotesDragSel = false end\n        return\n    end\n    local td = state._solfegeTextDragMove\n    if td and td.active then\n        state._solfegeTextDragMove = nil\n        if td.moved and td.dropPos then\n            moveSelectedSolfegeText(td.dropPos)\n        end\n        if state._solfegeDragSel then state._solfegeDragSel = false end\n        if state._lyricNotesDragSel then state._lyricNotesDragSel = false end\n        return\n    end\n    if state._cmdChatDraggingResize then\n        state._cmdChatDraggingResize = false\n        state._cmdChatDragStartY = nil\n        state._cmdChatDragStartH = nil\n        return\n    end\n    if state._cmdChatDragSel then\n        state._cmdChatDragSel = nil\n        if not cmdChatSelRange() then cmdChatClearSel() end\n        return\n    end\n    -- End text-selection drag\n    if state._solfegeDragSel then state._solfegeDragSel = false end\n    if state._lyricNotesDragSel then state._lyricNotesDragSel = false end\n    if state._allOptionsDragActive then\n        state._allOptionsDragActive = false; return\n    end\n    if lnSplitDrag.active then\n        lnSplitDrag.active = false\n        state._lnSplitDragActive = nil\n        savePreferences()\n        return\n    end\n    if solfegeBottomDrag.active then\n        solfegeBottomDrag.active = false\n        savePreferences()\n        return\n    end\n    if solfegeDrag.active then\n        solfegeDrag.active = false\n        solfegeDrag.startX = nil\n        solfegeDrag.startWidth = nil\n        solfegeDrag.side = nil\n        savePreferences()\n        return\n    end\n    if solfegeFloatDrag.active then\n        solfegeFloatDrag.active = false\n        savePreferences()\n        return\n    end\n    if solfegeFloatResize.active then\n        solfegeFloatResize.active = false\n        savePreferences()\n        return\n    end\n    if welcomeScrollDrag.active then\n        welcomeScrollDrag.active = false\n        return\n    end\n    -- Handle bpmDrag end for any button\n    if bpmDrag.active then\n        local moved = bpmDrag.moved\n        bpmDrag.active = false\n        bpmDrag.startY = nil\n        bpmDrag.startTempo = nil\n        bpmDrag.moved = false\n        if moved then savePreferences() end\n        return\n    end\n    if button ~= 1 then return end\n    if lyricTokenDrag.active then\n        if lyricTokenDrag.source == \"timeline\" and not lyricTokenDrag.moved then\n            -- Click on lyric text: open inline lyric text editor for that step\n            local stepIdx = lyricTokenDrag.stepIndex\n            if stepIdx then\n                local step = state.sequence[stepIdx]\n                state.lyricEditingStepIndex = stepIdx\n                state.lyricInputBuffer = ((step and step.lyric) or \"\"):gsub(\"\\n+$\", \"\")\n                state.lyricInputCursor = nil\n                state.lyricSelAnchor = nil\n                state.lyricSelFocus = nil\n            end\n        elseif lyricTokenDrag.source == \"lyric_notes\" and not lyricTokenDrag.moved then\n            state.lyricNotesEditingTokenIndex = lyricTokenDrag.tokenIndex\n            state.lyricNotesInputActive = false\n        else\n            if not dropLyricTokenOnSolfegeInput(lyricTokenDrag.token, x, y) then\n                local droppedOnLyricNotes = dropLyricTokenOnLyricNotes(lyricTokenDrag.token, x, y)\n                local droppedOnTimeline = false\n                if not droppedOnLyricNotes then\n                    droppedOnTimeline = dropLyricTokenOnTimeline(lyricTokenDrag.token, x, y)\n                end\n\n                if lyricTokenDrag.source == \"lyric_notes\" and droppedOnTimeline then\n                    removeLyricNotesToken(lyricTokenDrag.token)\n                elseif lyricTokenDrag.source == \"timeline\" and (droppedOnTimeline or droppedOnLyricNotes) then\n                    local srcIdx = lyricTokenDrag.stepIndex\n                    if srcIdx and state.sequence[srcIdx] then\n                        state.sequence[srcIdx].lyric = nil\n                        markSequenceDirty()\n                    end\n                end\n            end\n        end\n        lyricTokenDrag.active = false\n        lyricTokenDrag.token = nil\n        lyricTokenDrag.x = nil\n        lyricTokenDrag.y = nil\n        lyricTokenDrag.startX = nil\n        lyricTokenDrag.startY = nil\n        lyricTokenDrag.moved = false\n        lyricTokenDrag.source = nil\n        lyricTokenDrag.stepIndex = nil\n        state._lyricStepDragSel = false\n        return\n    end\n    if volumeSliderDrag.active then\n        volumeSliderDrag.active = false\n        savePreferences()\n        return\n    end\n    -- End drag-to-select or tap-to-toggle in multiSelectMode\n    if selectDrag.startIndex ~= nil and state.multiSelectMode then\n        local wasDrag = selectDrag.active\n        local si = selectDrag.startIndex\n        selectDrag.active = false\n        selectDrag.startIndex = nil\n        selectDrag.startX = nil\n        selectDrag.startY = nil\n        if not wasDrag then\n            -- Pure tap: selectedSteps unchanged since mouseDown, just toggle\n            if not state.selectedSteps then state.selectedSteps = {} end\n            if state.selectedSteps[si] then\n                state.selectedSteps[si] = nil\n            elseif state.sequence[si] then\n                state.selectedSteps[si] = true\n            end\n        end\n        -- Drag: mouseMove already merged savedSelection + range continuously, nothing to do\n        selectDrag.savedSelection = nil\n        return\n    end\n    -- If hold-pending never became a drag, treat as a selection click\n    if holdPending.active then\n        local si = holdPending.stepIndex\n        holdPending.active = false\n        holdPending.stepIndex = nil\n        holdPending.startTime = nil\n        holdPending.startX = nil\n        holdPending.startY = nil\n        if si then\n            if state.multiSelectMode then\n                -- Toggle step in multi-selection\n                if not state.selectedSteps then state.selectedSteps = {} end\n                if state.selectedSteps[si] then\n                    state.selectedSteps[si] = nil\n                elseif state.sequence[si] then\n                    state.selectedSteps[si] = true\n                end\n            elseif state.editMode == \"delete\" then\n                state.currentStep = si\n                tryDeleteCurrentStep()\n            elseif state.muteMode then\n                -- Toggle mute on clicked step\n                if state.sequence[si] then\n                    state.sequence[si].muted = not state.sequence[si].muted\n                    markSequenceDirty()\n                end\n            else\n                local wasCurrentStep = (state.currentStep == si)\n                activateStepSelection(si, wasCurrentStep)\n            end\n        end\n        return\n    end\n    holdPending.active = false\n    holdPending.stepIndex = nil\n    holdPending.startTime = nil\n    holdPending.startX = nil\n    holdPending.startY = nil\n    -- Commit reorder if active\n    if reorderDrag.active then\n        local from = reorderDrag.stepIndex\n        local to = reorderDrag.targetIndex\n        -- When dropping after the source, destination shifts by 1 once source is removed\n        if to > from then to = to - 1 end\n        if from ~= to then\n            if core.moveStep(state, state.activeSequenceIndex, from, to) then\n                if state.currentStep == from then\n                    state.currentStep = to\n                end\n                markSequenceDirty()\n            end\n        end\n        reorderDrag.active = false\n        reorderDrag.stepIndex = nil\n        reorderDrag.targetIndex = nil\n        return\n    end\n    -- End gate drag\n    if gateDrag.active then\n        local si = gateDrag.stepIndex\n        local moved = gateDrag.moved\n        gateDrag.active = false\n        gateDrag.stepIndex = nil\n        gateDrag.startMouseY = nil\n        gateDrag.startGate = nil\n        gateDrag.moved = false\n        if si and not moved then\n            -- Tap without drag: reset gate to nil (restores default 0.9)\n            if state.sequence[si] then\n                state.sequence[si].gate = nil\n                markSequenceDirty()\n            end\n        end\n        return\n    end\n    local snapIdx = stretchDrag.stepIndex\n    stretchDrag.active = false\n    stretchDrag.stepIndex = nil\n    stretchDrag.startCol = nil\n    stretchDrag.startRow = nil\n    stretchDrag.startMouseX = nil\n    stretchDrag.startLen = nil\n    stretchDrag.previewLen = nil\n    if snapIdx then\n        markSequenceDirty()\n    end\n    end\n\n    -- ANALOG input (crank) handler\n    callbacks.onAnalog = function(change, acceleratedChange)\n    if showWelcomeScreen then\n        if change ~= 0 then\n            local maxFirst = math.max(1, #welcomeRecentFiles - (state._welcomeVisibleCount or 5) + 1)\n            local first = state._welcomeFirstVisible or 1\n            state._welcomeFirstVisible = math.max(1, math.min(maxFirst, first + (change > 0 and 1 or -1)))\n        end\n        return\n    end\n    if state.bpmEditing or state.barEditing or state.beatEditing or state.seekEditing then return end\n    if state.lyricEditingStepIndex then return end  -- keyboard entry active; ignore scroll\n    -- Only adjust BPM when hovering over BPM header area or it's keyboard-selected.\n    -- Playdate crank (large delta >10) always adjusts; mouse wheel only when over BPM.\n    local mx, my = state.mouseX or 0, state.mouseY or 0\n\n    -- Scroll command chat activity when the pointer is over the message area.\n    local cb = state._cmdChatMessageBounds\n    if state.cmdChatOpen and cb and change ~= 0 and mx >= cb.x and mx < cb.x + cb.w and my >= cb.y and my < cb.y + cb.h then\n        local maxChatScroll = state._cmdChatScrollMax or 0\n        if maxChatScroll > 0 then\n            local step = math.max(1, math.min(6, math.floor(math.abs(change) + 0.5)))\n            local dir = change < 0 and 1 or -1\n            state.cmdChatScrollOffset = math.max(0, math.min(maxChatScroll, (state.cmdChatScrollOffset or 0) + dir * step))\n        end\n        return\n    end\n\n    -- Scroll solfege text box\n    local ib = state._solfegeInputBounds\n    if ib and change ~= 0 and mx >= ib.x and mx < ib.x + ib.w and my >= ib.y and my < ib.y + ib.h then\n        local vlc = state._solfegeVisLineCount or 1\n        local mvis = state._solfegeMaxVisLines or 3\n        local maxSL = math.max(0, vlc - mvis)\n        state.solfegeScrollLine = math.max(0, math.min(maxSL, (state.solfegeScrollLine or 0) + (change > 0 and 1 or -1)))\n        return\n    end\n\n    -- Scroll timeline grid\n    local gb = state._gridBounds\n    if gb and change ~= 0 and mx >= gb.x and mx < gb.x + gb.w and my >= gb.y and my < gb.y + gb.h then\n        local rn = state._gridRowsNeeded or 1\n        local rt = state._gridRowsToShow or 1\n        local maxRow = math.max(0, rn - rt)\n        if maxRow > 0 then\n            state.gridScrollRow = math.max(0, math.min(maxRow, (state.gridScrollRow or 0) + (change > 0 and 1 or -1)))\n        else\n            local maxCol = state._gridMaxScrollCol or 0\n            if maxCol > 0 then\n                state.gridScrollCol = math.max(0, math.min(maxCol, (state.gridScrollCol or 0) + (change > 0 and 1 or -1)))\n            end\n        end\n        return\n    end\n    local macTopRowH = isRunningOnMacDesktop() and 24 or 0\n    local subHeaderTopY = macTopRowH + 56 + (state._macFilenameRowH or 0)\n    local subHeaderBottomY = subHeaderTopY + (((state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\") and 24) or 0)\n    local bx = state._bpmHeaderX or 340\n    local bw = state._bpmHeaderW or 50\n    local mxh = state._meterHeaderX or 310\n    local mwh = state._meterHeaderW or 30\n    local overBpm = not state.singSolfegeMode and state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and mx >= (bx - 4) and mx < (bx + bw + 4) and my >= subHeaderTopY and my < subHeaderBottomY\n    local bpmSelected = state.headerSelection == 5 or state.bpmFocused\n    local isCrank = math.abs(change) > 10\n\n    -- Scroll over Step duration header: cycle step size\n    local sdxs = state._stepDurHeaderX or 0\n    local sdws = state._stepDurHeaderW or 0\n    local overStepDur = sdws > 0 and not state.singSolfegeMode and state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and mx >= (sdxs - 4) and mx < (sdxs + sdws + 4) and my >= subHeaderTopY and my < subHeaderBottomY\n    if state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and change ~= 0 and (overStepDur or state.headerSelection == 11) and not isCrank then\n        adjustStepBeats(change > 0 and 1 or -1)\n        savePreferences()\n        return\n    end\n\n    -- Scroll over Beats header: adjust beat count\n    local btx = state._beatsHeaderX or 0\n    local btw = state._beatsHeaderW or 0\n    local overBeats = btw > 0 and not state.singSolfegeMode and state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and mx >= (btx - 4) and mx < (btx + btw + 4) and my >= subHeaderTopY and my < subHeaderBottomY\n    if state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and change ~= 0 and (overBeats or state.headerSelection == 10) and not isCrank then\n        adjustBeatCount(change > 0 and 1 or -1)\n        return\n    end\n\n    -- Scroll over Bars/Steps header: adjust bar count (or step count)\n    local slx = state._seqLenHeaderX or 8\n    local slw = state._seqLenHeaderW or 60\n    local overSeqLen = not state.singSolfegeMode and state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and mx >= (slx - 4) and mx < (slx + slw + 4) and my >= subHeaderTopY and my < subHeaderBottomY\n    if state.showBarsBeatsRow and (state.solfegeTextMode or \"both\") ~= \"lyrics\" and change ~= 0 and (overSeqLen or state.headerSelection == 4) and not isCrank then\n        adjustBarCount(change > 0 and 1 or -1)\n        return\n    end\n\n    if change ~= 0 and (overBpm or bpmSelected or isCrank) then\n        if change > 0 then\n            setTempo(state.tempo + 5)\n        else\n            setTempo(state.tempo - 5)\n        end\n    end\n    end\n\n    -- RAW KEY handler — used for direct BPM text entry\n    callbacks.onRawKey = function(key)\n        -- Track modifier keys; consume the event so they don't trigger other actions\n        if key == \"left shift\" or key == \"right shift\" then\n            _shiftHeld = true; return\n        end\n        if key == \"left gui\" or key == \"right gui\"\n           or key == \"left command\" or key == \"right command\"\n           or key == \"left ctrl\" or key == \"right ctrl\" then\n            _cmdHeld = true; return\n        end\n        if key == \"left alt\" or key == \"right alt\" then\n            _altHeld = true; return\n        end\n\n        -- Dismiss shortcut help overlay on any key (? toggles it back on)\n        if state.showShortcutHelp and key ~= \"?\" then\n            state.showShortcutHelp = false\n            return\n        end\n\n        -- Ear training mode keyboard handling\n        if state.earTrainingMode then\n            if key == \"escape\" then\n                core._et.exit()\n                return\n            end\n            local q = state.earTrainingQuestion\n            if q and not state.earTrainingRevealed then\n                if q.type == \"dictation\" then\n                    local buf = state.earTrainingDictationInput or \"\"\n                    if key == \"backspace\" then\n                        if #buf > 0 then\n                            state.earTrainingDictationInput = buf:sub(1, -2)\n                        end\n                        return\n                    elseif key == \"return\" then\n                        core._et.submit(buf)\n                        return\n                    elseif key == \"space\" then\n                        state.earTrainingDictationInput = buf .. \" \"\n                        return\n                    elseif #key == 1 and key:match(\"[a-zA-Z'%-]\") then\n                        state.earTrainingDictationInput = buf .. key\n                        return\n                    end\n                elseif q.options then\n                    local numKey = tonumber(key)\n                    if numKey and numKey >= 1 and numKey <= #q.options then\n                        state.earTrainingSelectedOption = numKey\n                        core._et.submit(q.options[numKey])\n                        return\n                    end\n                end\n            end\n            if state.earTrainingRevealed and (key == \"return\" or key == \"n\") then\n                core._et.next()\n                return\n            end\n            if key == \"space\" or key == \"r\" then\n                core._et.playQuestion()\n                return\n            end\n            return\n        end\n\n        -- Cancel quick MIDI Learn (m-key shortcut) on Escape\n        if key == \"escape\" and state.midiLearnMode\n           and not state.showingMidiControls then\n            state.midiLearnMode = false\n            state.midiLearnTarget = nil\n            return\n        end\n\n        if state.scaleStepsInputOpen then\n            local buf = state.scaleStepsInputBuffer or \"\"\n            if key == \"escape\" then\n                state.scaleStepsInputOpen = false\n                state.scaleStepsInputBuffer = nil\n                state.scaleStepsInputError = nil\n            elseif key == \"return\" then\n                local ok, err = _scaleOps.applyCustom(buf)\n                if ok then\n                    local oldScale = state.solfegeScale or \"major\"\n                    state.solfegeScale = \"custom\"\n                    _scaleOps.remapNotesForScaleChange(oldScale, \"custom\")\n                    refreshSolfegeNotes()\n                    markSequenceDirty()\n                    savePreferences()\n                    state.scaleStepsInputOpen = false\n                    state.scaleStepsInputBuffer = nil\n                    state.scaleStepsInputError = nil\n                else\n                    state.scaleStepsInputError = err\n                end\n            elseif key == \"backspace\" then\n                if _cmdHeld then\n                    state.scaleStepsInputBuffer = \"\"\n                    state.scaleStepsInputError = nil\n                elseif #buf > 0 then\n                    state.scaleStepsInputBuffer = buf:sub(1, -2)\n                    state.scaleStepsInputError = nil\n                end\n            elseif _cmdHeld and key == \"v\" then\n                local pasted = clipboardRead():gsub(\"[^0-9 ]\", \"\")\n                local combined = (buf .. pasted):sub(1, 40)\n                state.scaleStepsInputBuffer = combined\n                state.scaleStepsInputError = nil\n            elseif key == \"space\" then\n                state.scaleStepsInputBuffer = buf .. \" \"\n                state.scaleStepsInputError = nil\n            elseif #key == 1 and key >= \"0\" and key <= \"9\" then\n                if #buf < 40 then\n                    state.scaleStepsInputBuffer = buf .. key\n                    state.scaleStepsInputError = nil\n                end\n            end\n            return\n        end\n\n        if state.cmdChatInputActive then\n            local buf = state.cmdChatInputBuffer or \"\"\n            local cur = state.cmdChatInputCursor or #buf\n            state.cmdChatCursorResetTime = os.clock()\n            if key == \"escape\" then\n                state.cmdChatInputActive = false\n                state.cmdChatOpen = false\n            elseif key == \"return\" then\n                if #buf > 0 then\n                    state.cmdChatHistory = state.cmdChatHistory or {}\n                    if state.cmdChatHistory[#state.cmdChatHistory] ~= buf then\n                        table.insert(state.cmdChatHistory, buf)\n                    end\n                    while #state.cmdChatHistory > 50 do\n                        table.remove(state.cmdChatHistory, 1)\n                    end\n                    state.cmdChatHistoryIndex = nil\n                    table.insert(state.cmdChatMessages, {role = \"user\", text = buf})\n                    local response = cmdChat.execute(buf)\n                    if response then\n                        table.insert(state.cmdChatMessages, response)\n                    end\n                    state.cmdChatInputBuffer = \"\"\n                    state.cmdChatInputCursor = 0\n                    cmdChatClearSel()\n                    state.cmdChatScrollOffset = 0\n                    if #state.cmdChatMessages > 100 then\n                        table.remove(state.cmdChatMessages, 1)\n                    end\n                end\n            elseif key == \"backspace\" then\n                if cmdChatDeleteSel() then\n                    -- selection removed\n                elseif _cmdHeld then\n                    state.cmdChatInputBuffer = \"\"\n                    state.cmdChatInputCursor = 0\n                    state.cmdChatHistoryIndex = nil\n                elseif cur > 0 then\n                    state.cmdChatInputBuffer = buf:sub(1, cur - 1) .. buf:sub(cur + 1)\n                    state.cmdChatInputCursor = cur - 1\n                    state.cmdChatHistoryIndex = nil\n                end\n            elseif key == \"delete\" then\n                if cmdChatDeleteSel() then\n                    -- selection removed\n                elseif cur < #buf then\n                    state.cmdChatInputBuffer = buf:sub(1, cur) .. buf:sub(cur + 2)\n                    state.cmdChatHistoryIndex = nil\n                end\n            elseif key == \"up\" then\n                if _cmdHeld then\n                    state.cmdChatScrollOffset = (state.cmdChatScrollOffset or 0) + 1\n                else\n                    local history = state.cmdChatHistory or {}\n                    if #history > 0 then\n                        local index = state.cmdChatHistoryIndex or (#history + 1)\n                        index = math.max(1, index - 1)\n                        state.cmdChatHistoryIndex = index\n                        state.cmdChatInputBuffer = history[index] or \"\"\n                        state.cmdChatInputCursor = #(state.cmdChatInputBuffer or \"\")\n                        cmdChatClearSel()\n                    end\n                end\n            elseif key == \"down\" then\n                if _cmdHeld then\n                    state.cmdChatScrollOffset = math.max(0, (state.cmdChatScrollOffset or 0) - 1)\n                else\n                    local history = state.cmdChatHistory or {}\n                    local index = state.cmdChatHistoryIndex\n                    if index then\n                        index = index + 1\n                        if index > #history then\n                            state.cmdChatHistoryIndex = nil\n                            state.cmdChatInputBuffer = \"\"\n                            state.cmdChatInputCursor = 0\n                            cmdChatClearSel()\n                        else\n                            state.cmdChatHistoryIndex = index\n                            state.cmdChatInputBuffer = history[index] or \"\"\n                            state.cmdChatInputCursor = #(state.cmdChatInputBuffer or \"\")\n                            cmdChatClearSel()\n                        end\n                    end\n                end\n            elseif key == \"left\" then\n                local lo = cmdChatSelRange()\n                if lo then\n                    state.cmdChatInputCursor = lo\n                    cmdChatClearSel()\n                else\n                    state.cmdChatInputCursor = math.max(0, cur - 1)\n                end\n            elseif key == \"right\" then\n                local lo, hi = cmdChatSelRange()\n                if lo then\n                    state.cmdChatInputCursor = hi\n                    cmdChatClearSel()\n                else\n                    state.cmdChatInputCursor = math.min(#buf, cur + 1)\n                end\n            elseif key == \"home\" then\n                state.cmdChatInputCursor = 0\n                cmdChatClearSel()\n            elseif key == \"end\" then\n                state.cmdChatInputCursor = #buf\n                cmdChatClearSel()\n            elseif _cmdHeld and key == \"a\" then\n                state.cmdChatInputCursor = #buf\n                state.cmdChatSelAnchor = 0\n                state.cmdChatSelFocus = #buf\n            elseif _cmdHeld and key == \"c\" then\n                local lo, hi = cmdChatSelRange()\n                if lo then clipboardWrite(buf:sub(lo + 1, hi)) end\n            elseif _cmdHeld and key == \"x\" then\n                local lo, hi = cmdChatSelRange()\n                if lo then\n                    clipboardWrite(buf:sub(lo + 1, hi))\n                    cmdChatDeleteSel()\n                end\n            elseif _cmdHeld and key == \"v\" then\n                local pasted = clipboardRead():gsub(\"\\n\", \" \")\n                cmdChatReplaceSelection(pasted)\n            elseif key == \"space\" then\n                cmdChatReplaceSelection(\" \")\n            elseif #key == 1 and not _cmdHeld then\n                cmdChatReplaceSelection(key)\n            end\n            return\n        end\n\n        if state.musicXMLFilenameEditing then\n            local buf = state.musicXMLFilenameInputBuffer or \"\"\n            local cur = state.musicXMLFilenameInputCursor or #buf\n            if key == \"escape\" then\n                cancelMusicXMLFilenameEditing()\n            elseif key == \"return\" then\n                commitMusicXMLFilenameEditing()\n            elseif key == \"backspace\" then\n                if cur > 0 then\n                    state.musicXMLFilenameInputBuffer = buf:sub(1, cur - 1) .. buf:sub(cur + 1)\n                    state.musicXMLFilenameInputCursor = cur - 1\n                end\n            elseif key == \"delete\" then\n                if cur < #buf then\n                    state.musicXMLFilenameInputBuffer = buf:sub(1, cur) .. buf:sub(cur + 2)\n                end\n            elseif key == \"left\" then\n                state.musicXMLFilenameInputCursor = math.max(0, cur - 1)\n            elseif key == \"right\" then\n                state.musicXMLFilenameInputCursor = math.min(#buf, cur + 1)\n            elseif _cmdHeld and key == \"c\" then\n                clipboardWrite(buf)\n            elseif _cmdHeld and key == \"x\" then\n                clipboardWrite(buf)\n                state.musicXMLFilenameInputBuffer = \"\"\n                state.musicXMLFilenameInputCursor = 0\n            elseif _cmdHeld and key == \"v\" then\n                local text = clipboardRead()\n                if text and #text > 0 then\n                    local allowed = math.min(#text, 80 - #buf)\n                    if allowed > 0 then\n                        text = text:sub(1, allowed)\n                        state.musicXMLFilenameInputBuffer = buf:sub(1, cur) .. text .. buf:sub(cur + 1)\n                        state.musicXMLFilenameInputCursor = cur + #text\n                    end\n                end\n            elseif key == \"space\" then\n                if #buf < 80 then\n                    state.musicXMLFilenameInputBuffer = buf:sub(1, cur) .. \" \" .. buf:sub(cur + 1)\n                    state.musicXMLFilenameInputCursor = cur + 1\n                end\n            elseif #key == 1 then\n                if #buf < 80 then\n                    state.musicXMLFilenameInputBuffer = buf:sub(1, cur) .. key .. buf:sub(cur + 1)\n                    state.musicXMLFilenameInputCursor = cur + 1\n                end\n            end\n            return\n        end\n\n        -- MIDI Controls screen: X/Delete = clear selected mapping, Escape = cancel learn\n        if state.showingMidiControls then\n            if key == \"x\" or key == \"delete\" or key == \"backspace\" then\n                if state.midiLearnMode then\n                    state.midiLearnMode = false\n                    state.midiLearnTarget = nil\n                else\n                    local act = MIDI_ACTIONS[state.midiControlsSelection]\n                    if act then\n                        state.midiMappings[act.id] = nil\n                        savePreferences()\n                    end\n                end\n                return\n            end\n            if key == \"return\" or key == \"space\" then\n                local act = MIDI_ACTIONS[state.midiControlsSelection]\n                if act and not state.midiLearnMode then\n                    state.midiLearnMode = true\n                    state.midiLearnTarget = act.id\n                end\n                return\n            end\n            return\n        end\n        if state.lyricNotesPanelOpen and state.lyricNotesInputActive and not state.solfegeInputActive then\n            if state.lyricNotesEditingTokenIndex then\n                local editIdx = state.lyricNotesEditingTokenIndex\n                if key == \"escape\" or key == \"return\" then\n                    state.lyricNotesEditingTokenIndex = nil\n                elseif key == \"backspace\" then\n                    local toks = LyricsImport.tokenizeLyricsText(state.lyricNotesBuffer or \"\")\n                    if toks[editIdx] then\n                        local t = toks[editIdx]:sub(1, -2)\n                        if t == \"\" then\n                            table.remove(toks, editIdx)\n                            state.lyricNotesEditingTokenIndex = nil\n                        else\n                            toks[editIdx] = t\n                        end\n                        state.lyricNotesBuffer = table.concat(toks, \" \")\n                    end\n                elseif #key == 1 then\n                    local toks = LyricsImport.tokenizeLyricsText(state.lyricNotesBuffer or \"\")\n                    if toks[editIdx] then\n                        toks[editIdx] = toks[editIdx] .. key\n                        state.lyricNotesBuffer = table.concat(toks, \" \")\n                    end\n                end\n                markSequenceDirty()\n                return\n            end\n            if key == \"escape\" then\n                lyricNotesClearSel()\n                state.lyricNotesPanelOpen = false\n                state.lyricNotesCursor = nil\n                if not state.solfegeInputActive then\n                    state.solfegeInputBuffer = state._solfegeSeqText or state.solfegeInputBuffer\n                end\n            elseif key == \"return\" then\n                if importLyricNotesText(state.lyricNotesBuffer or \"\") then\n                    state.lyricNotesBuffer = \"\"\n                    state.lyricNotesCursor = nil\n                    lyricNotesClearSel()\n                    state.lyricNotesPanelOpen = false\n                    if not state.solfegeInputActive then\n                        state.solfegeInputBuffer = state._solfegeSeqText or state.solfegeInputBuffer\n                    end\n                end\n            elseif _cmdHeld and key == \"a\" then\n                textEditorSelectAll(\"lyric_notes\")\n            elseif _cmdHeld and key == \"c\" then\n                copyLyricNotesSelection()\n            elseif _cmdHeld and key == \"x\" then\n                cutLyricNotesSelection()\n            elseif _cmdHeld and key == \"v\" then\n                pasteIntoLyricNotesText()\n            elseif key == \"left\" then\n                textEditorMoveCursor(\"lyric_notes\", -1, _shiftHeld)\n            elseif key == \"right\" then\n                textEditorMoveCursor(\"lyric_notes\", 1, _shiftHeld)\n            elseif key == \"backspace\" or key == \"delete\" then\n                textEditorBackspace(\"lyric_notes\")\n            elseif key == \"space\" or (#key == 1 and not _cmdHeld) then\n                textEditorInsert(\"lyric_notes\", (key == \"space\") and \" \" or key)\n            end\n            return\n        end\n\n        -- Lyric editing keyboard input\n        if state.lyricEditingStepIndex then\n            local si = state.lyricEditingStepIndex\n            if key == \"escape\" then\n                state.lyricEditingStepIndex = nil\n                state.lyricInputBuffer = nil\n                state.lyricInputCursor = nil\n                state.lyricSelAnchor = nil\n                state.lyricSelFocus = nil\n            elseif key == \"return\" then\n                if commitLyricBufferToStep(si) then\n                    markSequenceDirty()\n                end\n                state.lyricEditingStepIndex = nil\n                state.lyricInputBuffer = nil\n                state.lyricInputCursor = nil\n                state.lyricSelAnchor = nil\n                state.lyricSelFocus = nil\n            elseif _cmdHeld and (key == \"left\" or key == \"right\") then\n                local lyricChanged = commitLyricBufferToStep(si)\n                local delta = (key == \"left\") and -1 or 1\n                local target = si + delta\n                if target >= 1 and target <= state.sequenceLength then\n                    if not state.sequence[target] then\n                        state.sequence[target] = {}\n                    end\n                    if core.moveStepLyric(state, state.activeSequenceIndex, si, target) then\n                        state.lyricEditingStepIndex = target\n                        state.lyricInputBuffer = (state.sequence[target].lyric or \"\"):gsub(\"\\n+$\", \"\")\n                        state.lyricInputCursor = nil\n                        state.lyricSelAnchor = nil\n                        state.lyricSelFocus = nil\n                        markSequenceDirty()\n                    elseif lyricChanged then\n                        markSequenceDirty()\n                    end\n                elseif lyricChanged then\n                    markSequenceDirty()\n                end\n            elseif key == \"left\" then\n                textEditorMoveCursor(\"lyric_step\", -1, _shiftHeld)\n            elseif key == \"right\" then\n                textEditorMoveCursor(\"lyric_step\", 1, _shiftHeld)\n            elseif _cmdHeld and key == \"a\" then\n                textEditorSelectAll(\"lyric_step\")\n            elseif _cmdHeld and key == \"c\" then\n                copyLyricStepSelection()\n            elseif _cmdHeld and key == \"x\" then\n                cutLyricStepSelection()\n            elseif _cmdHeld and key == \"v\" then\n                pasteIntoLyricStepText()\n            elseif key == \"backspace\" or key == \"delete\" then\n                textEditorBackspace(\"lyric_step\")\n            elseif key == \"space\" or (#key == 1 and not _cmdHeld) then\n                textEditorInsert(\"lyric_step\", (key == \"space\") and \" \" or key)\n            end\n            return\n        end\n\n        -- Seek position editing (click position counter while playing/paused, type a bar number)\n        if state.seekEditing then\n            if key == \"escape\" then\n                state.seekEditing = false\n                state.seekInputBuffer = nil\n            elseif key == \"return\" then\n                commitSeekEdit()\n            elseif key == \"backspace\" then\n                local buf = state.seekInputBuffer or \"\"\n                if #buf > 0 then state.seekInputBuffer = buf:sub(1, -2) end\n            elseif key >= \"0\" and key <= \"9\" then\n                local buf = state.seekInputBuffer or \"\"\n                if #buf < 3 then state.seekInputBuffer = buf .. key end\n            end\n            return\n        end\n\n        -- Bar count editing\n        if state.barEditing then\n            if key == \"escape\" then\n                state.barEditing = false\n                state.barInputBuffer = nil\n            elseif key == \"return\" then\n                commitBarEdit()\n            elseif key == \"backspace\" then\n                local buf = state.barInputBuffer or \"\"\n                if #buf > 0 then state.barInputBuffer = buf:sub(1, -2) end\n            elseif key >= \"0\" and key <= \"9\" then\n                local buf = state.barInputBuffer or \"\"\n                if #buf < 3 then state.barInputBuffer = buf .. key end\n            end\n            return\n        end\n        -- Beat count editing\n        if state.beatEditing then\n            if key == \"escape\" then\n                state.beatEditing = false\n                state.beatInputBuffer = nil\n            elseif key == \"return\" then\n                commitBeatEdit()\n            elseif key == \"backspace\" then\n                local buf = state.beatInputBuffer or \"\"\n                if #buf > 0 then state.beatInputBuffer = buf:sub(1, -2) end\n            elseif key >= \"0\" and key <= \"9\" then\n                local buf = state.beatInputBuffer or \"\"\n                if #buf < 3 then state.beatInputBuffer = buf .. key end\n            end\n            return\n        end\n\n        -- Return when key label is focused: preview the key note without cycling it\n        if state.keyFocused and key == \"return\" then\n            playKeyCenterPreview()\n            return\n        end\n\n        -- Scale dropdown type-ahead\n        if state.solfegeScaleDropdownOpen then\n            if key == \"escape\" then\n                state.solfegeScaleDropdownOpen = false\n                state._scaleDropdownSearch = nil\n                state._scaleDropdownPending = nil\n                return\n            elseif key == \"return\" then\n                local pending = state._scaleDropdownPending\n                state.solfegeScaleDropdownOpen = false\n                state._scaleDropdownSearch = nil\n                state._scaleDropdownPending = nil\n                if pending then\n                    if pending == \"custom\" then\n                        openScaleStepsInput()\n                    elseif state.solfegeScale ~= pending then\n                        local oldScale = state.solfegeScale or \"major\"\n                        state.solfegeScale = pending\n                        _scaleOps.remapNotesForScaleChange(oldScale, pending)\n                        refreshSolfegeNotes()\n                        snapKeyNoteToScale()\n                        markSequenceDirty()\n                        savePreferences()\n                    end\n                end\n                return\n            elseif key == \"backspace\" then\n                local s = state._scaleDropdownSearch or \"\"\n                s = s:sub(1, -2)\n                state._scaleDropdownSearch = s\n                state._scaleDropdownPending = _findScaleMatch(s)\n                return\n            elseif #key == 1 and not _cmdHeld then\n                local s = (state._scaleDropdownSearch or \"\") .. key\n                state._scaleDropdownSearch = s\n                state._scaleDropdownPending = _findScaleMatch(s)\n                return\n            end\n        end\n\n        -- Search input routing (when template menu open and not in save/rename mode)\n        if state._solfegeTemplateMenuOpen and not state._solfegeTemplateSaving and not state._solfegeTemplateRenaming then\n            if key == \"escape\" then\n                if (state._solfegeTemplateSearch or \"\") ~= \"\" then\n                    state._solfegeTemplateSearch = \"\"\n                else\n                    state._solfegeTemplateMenuOpen = false\n                end\n                return\n            elseif key == \"backspace\" then\n                local s = state._solfegeTemplateSearch or \"\"\n                state._solfegeTemplateSearch = s:sub(1, -2)\n                return\n            elseif #key == 1 and not _cmdHeld and #(state._solfegeTemplateSearch or \"\") < 30 then\n                state._solfegeTemplateSearch = (state._solfegeTemplateSearch or \"\") .. key\n                return\n            end\n        end\n\n        -- Template name input (save or rename)\n        if state._solfegeTemplateSaving or state._solfegeTemplateRenaming then\n            local isSaving = state._solfegeTemplateSaving\n            local buf = isSaving\n                        and (state._solfegeTemplateSaveName or \"\")\n                        or  (state._solfegeTemplateRenaming.name or \"\")\n            if key == \"escape\" then\n                state._solfegeTemplateSaving = nil\n                state._solfegeTemplateSaveName = nil\n                state._solfegeTemplateRenaming = nil\n            elseif key == \"return\" then\n                if isSaving then\n                    saveCurrentAsUserTemplate(buf)\n                    state._solfegeTemplateSavedFlash = os.clock()\n                    state._solfegeTemplateSaving = nil\n                    state._solfegeTemplateSaveName = nil\n                    state._solfegeTemplateMenuOpen = false\n                else\n                    renameUserSolfegeTemplate(state._solfegeTemplateRenaming.index, buf)\n                    state._solfegeTemplateRenaming = nil\n                end\n            elseif key == \"backspace\" then\n                if #buf > 0 then\n                    local newBuf = buf:sub(1, -2)\n                    if isSaving then state._solfegeTemplateSaveName = newBuf\n                    else state._solfegeTemplateRenaming.name = newBuf end\n                end\n            elseif (key == \"space\" or (#key == 1 and not _cmdHeld)) and #buf < 40 then\n                local ch = (key == \"space\") and \" \" or key\n                local newBuf = buf .. ch\n                if isSaving then state._solfegeTemplateSaveName = newBuf\n                else state._solfegeTemplateRenaming.name = newBuf end\n            end\n            return\n        end\n\n        -- Solfege text input at bottom\n        if state.solfegeInputActive then\n            state._solfegeLastCursorActivity = os.clock()  -- keep cursor solid after keypress\n\n            -- Template picker navigation (intercept before autocomplete and regular key handling)\n            if state._solfegeTemplatePicker then\n                if key == \"tab\" or (key == \"return\" and not _cmdHeld) then\n                    _pushSolfegeTextUndoState(true)\n                    _acceptSolfegeTemplatePicker()\n                    return\n                elseif key == \"escape\" then\n                    state._solfegeTemplatePicker = nil\n                    return\n                elseif key == \"up\" then\n                    local tp = state._solfegeTemplatePicker\n                    tp.sel = math.max(1, (tp.sel or 1) - 1)\n                    if tp.sel - 1 < (tp.scrollTop or 0) then tp.scrollTop = tp.sel - 1 end\n                    return\n                elseif key == \"down\" then\n                    local tp = state._solfegeTemplatePicker\n                    local maxVis = 8\n                    tp.sel = math.min(#tp.items, (tp.sel or 1) + 1)\n                    if tp.sel > (tp.scrollTop or 0) + maxVis then tp.scrollTop = tp.sel - maxVis end\n                    return\n                end\n            end\n\n            -- Autocomplete navigation (intercept before regular key handling)\n            if state._solfegeAutocomplete then\n                if key == \"tab\" or (key == \"return\" and not _cmdHeld) then\n                    _pushSolfegeTextUndoState(true)\n                    _acceptSolfegeAutocomplete()\n                    return\n                elseif key == \"escape\" then\n                    state._solfegeAutocomplete = nil\n                    return\n                elseif key == \"up\" then\n                    local ac = state._solfegeAutocomplete\n                    ac.sel = math.max(1, (ac.sel or 1) - 1)\n                    return\n                elseif key == \"down\" then\n                    local ac = state._solfegeAutocomplete\n                    ac.sel = math.min(#ac.items, (ac.sel or 1) + 1)\n                    return\n                end\n            end\n\n            -- Clear sticky-X column used by up/down navigation on any non-vertical key\n            if key ~= \"up\" and key ~= \"down\" then state._solfegeStickyX = nil end\n            local buf = state.solfegeInputBuffer or \"\"\n            local cur = state.solfegeInputCursor  -- nil = after last char\n\n            -- Helper: current absolute cursor position (1..#buf+1)\n            local function curPos() return cur or (#buf + 1) end\n\n            if not _cachedTextEdit then _cachedTextEdit = require(\"text_edit\") end\n            local wJumpLeft  = _cachedTextEdit.wordJumpLeft\n            local wJumpRight = _cachedTextEdit.wordJumpRight\n            local lineStart  = _cachedTextEdit.lineStart\n            local lineEnd    = _cachedTextEdit.lineEnd\n\n            if key == \"escape\" then\n                state.solfegeInputActive = false\n                state.solfegeInputCursor = nil\n                state._lyricsSnapshot = nil\n                state.solfegeInputBuffer = \"\"\n                solfegeClearSel()\n                _flushLiveApply()\n                _requestLiveApply(true)\n            elseif _cmdHeld and key == \"a\" then\n                -- Select all\n                if #buf > 0 then\n                    state.solfegeSelAnchor = 1\n                    state.solfegeSelFocus  = #buf + 1\n                    state.solfegeInputCursor = nil\n                end\n            elseif _cmdHeld and key == \"z\" then\n                if _shiftHeld then\n                    redoSolfegeTextEdit()\n                else\n                    undoSolfegeTextEdit()\n                end\n            elseif _cmdHeld and key == \"c\" then\n                copySolfegeSelection()\n            elseif _cmdHeld and key == \"x\" then\n                cutSolfegeSelection()\n            elseif _cmdHeld and key == \"v\" then\n                pasteIntoSolfegeText()\n            elseif _cmdHeld and key == \"return\" then\n                _flushLiveApply()\n                if state.isPlaying then pausePlayback() else startPlayback() end\n            elseif key == \"return\" then\n                _pushSolfegeTextUndoState(true)\n                -- If selection active, delete it first\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    buf = state.solfegeInputBuffer or \"\"\n                    cur = state.solfegeInputCursor\n                end\n                -- Insert a newline at cursor; cap at two consecutive \\n (one blank line max)\n                if cur then\n                    local before = buf:sub(1, cur - 1):gsub(\" +$\", \"\")\n                    if not before:match(\"\\n\\n$\") then\n                        local after = buf:sub(cur)\n                        state.solfegeInputBuffer = before .. \"\\n\" .. after\n                        state.solfegeInputCursor = #before + 2\n                        _requestLiveApply(true)\n                    end\n                else\n                    local before = buf:gsub(\" +$\", \"\")\n                    if not before:match(\"\\n\\n$\") then\n                        state.solfegeInputBuffer = before .. \"\\n\"\n                        _requestLiveApply(true)\n                    end\n                end\n            elseif key == \"tab\" then\n                _pushSolfegeTextUndoState(true)\n                solfegeClearSel()\n                state.solfegeInputBuffer = serializeSequenceToText()\n                state.solfegeInputCursor = nil\n                _requestLiveApply(true)\n            elseif _cmdHeld and key == \"backspace\" then\n                -- Delete to line start (Cmd+Backspace)\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    _requestLiveApply(false)\n                else\n                    local cp = curPos()\n                    local ls = lineStart(buf, cp)\n                    if ls < cp then\n                        state.solfegeInputBuffer = buf:sub(1, ls - 1) .. buf:sub(cp)\n                        local newBuf = state.solfegeInputBuffer\n                        state.solfegeInputCursor = (ls > #newBuf) and nil or ls\n                        _requestLiveApply(false)\n                    end\n                end\n            elseif _altHeld and key == \"backspace\" then\n                -- Delete word before cursor (Option+Backspace)\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    _requestLiveApply(false)\n                else\n                    local cp = curPos()\n                    local newPos = wJumpLeft(buf, cp)\n                    if newPos < cp then\n                        state.solfegeInputBuffer = buf:sub(1, newPos - 1) .. buf:sub(cp)\n                        local newBuf = state.solfegeInputBuffer\n                        state.solfegeInputCursor = (newPos > #newBuf) and nil or newPos\n                        _requestLiveApply(false)\n                    end\n                end\n            elseif key == \"backspace\" then\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    _requestLiveApply(false)\n                elseif cur then\n                    if cur > 1 then\n                        state.solfegeInputBuffer = buf:sub(1, cur - 2) .. buf:sub(cur)\n                        local newCur = cur - 1\n                        state.solfegeInputCursor = (newCur > #state.solfegeInputBuffer) and nil or newCur\n                        _requestLiveApply(false)\n                    end\n                elseif #buf > 0 then\n                    state.solfegeInputBuffer = buf:sub(1, -2)\n                    _requestLiveApply(false)\n                end\n            elseif _cmdHeld and key == \"delete\" then\n                -- Delete to line end (Cmd+Delete)\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    _requestLiveApply(false)\n                else\n                    local cp = curPos()\n                    local le = lineEnd(buf, cp)\n                    if le > cp then\n                        state.solfegeInputBuffer = buf:sub(1, cp - 1) .. buf:sub(le)\n                        local newBuf = state.solfegeInputBuffer\n                        state.solfegeInputCursor = (cp > #newBuf) and nil or cp\n                        _requestLiveApply(false)\n                    end\n                end\n            elseif _altHeld and key == \"delete\" then\n                -- Delete word after cursor (Option+Delete)\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    _requestLiveApply(false)\n                else\n                    local cp = curPos()\n                    local newEnd = wJumpRight(buf, cp)\n                    if newEnd > cp then\n                        state.solfegeInputBuffer = buf:sub(1, cp - 1) .. buf:sub(newEnd)\n                        local newBuf = state.solfegeInputBuffer\n                        state.solfegeInputCursor = (cp > #newBuf) and nil or cp\n                        _requestLiveApply(false)\n                    end\n                end\n            elseif key == \"delete\" then\n                -- Forward delete: remove char after cursor\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    _requestLiveApply(false)\n                elseif cur then\n                    state.solfegeInputBuffer = buf:sub(1, cur - 1) .. buf:sub(cur + 1)\n                    local newBuf = state.solfegeInputBuffer\n                    state.solfegeInputCursor = (cur > #newBuf) and nil or cur\n                    _requestLiveApply(false)\n                end\n                -- cur == nil means cursor is at end; nothing to delete forward\n            elseif _cmdHeld and key == \"left\" then\n                -- Jump to line start (Cmd+Left)\n                local oldP = curPos()\n                local newPos = lineStart(buf, oldP)\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = newPos\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = (newPos > #buf) and nil or newPos\n            elseif _cmdHeld and key == \"right\" then\n                -- Jump to line end (Cmd+Right)\n                local oldP = curPos()\n                local newPos = lineEnd(buf, oldP)\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = newPos\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = (newPos > #buf) and nil or newPos\n            elseif _altHeld and key == \"left\" then\n                -- Word jump left (Option+Left)\n                local oldP = curPos()\n                local newPos = wJumpLeft(buf, oldP)\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = newPos\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = (newPos > #buf) and nil or newPos\n            elseif _altHeld and key == \"right\" then\n                -- Word jump right (Option+Right)\n                local oldP = curPos()\n                local newPos = wJumpRight(buf, oldP)\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = newPos\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = (newPos > #buf) and nil or newPos\n            elseif key == \"left\" and (state.solfegeTextMode or \"both\") == \"steps\"\n                   and not _shiftHeld and not _altHeld and not _cmdHeld then\n                -- Step-token navigation: jump to previous step and select it\n                local cp = curPos()\n                local idx, ts, te = _solfegeStepAtPos(buf, cp - 1)\n                if idx then\n                    if cp <= ts and idx > 1 then\n                        idx = idx - 1\n                    end\n                    local ns, ne = _solfegeStepTokenRange(buf, idx)\n                    if ns then\n                        solfegeClearSel()\n                        state.solfegeSelAnchor = ns\n                        state.solfegeSelFocus = ne + 1\n                        state.solfegeInputCursor = (ne + 1 > #buf) and nil or (ne + 1)\n                        state.currentStep = idx\n                        state._solfegeLastCursorActivity = os.clock()\n                    end\n                end\n            elseif key == \"left\" then\n                local oldP = curPos()\n                local lo, hi = solfegeSelRange()\n                if _shiftHeld then\n                    if cur then\n                        cur = (cur > 1) and (cur - 1) or 1\n                    else\n                        cur = (#buf > 0) and #buf or nil\n                    end\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = cur or (#buf + 1)\n                elseif lo then\n                    cur = (lo > #buf) and nil or lo\n                    solfegeClearSel()\n                else\n                    if cur then\n                        cur = (cur > 1) and (cur - 1) or 1\n                    else\n                        cur = (#buf > 0) and #buf or nil\n                    end\n                end\n                state.solfegeInputCursor = cur\n            elseif key == \"right\" and (state.solfegeTextMode or \"both\") == \"steps\"\n                   and not _shiftHeld and not _altHeld and not _cmdHeld then\n                -- Step-token navigation: jump to next step and select it\n                local cp = curPos()\n                local idx, ts, te = _solfegeStepAtPos(buf, cp)\n                if idx then\n                    local nextIdx = idx + 1\n                    local ns, ne = _solfegeStepTokenRange(buf, nextIdx)\n                    if ns then\n                        solfegeClearSel()\n                        state.solfegeSelAnchor = ns\n                        state.solfegeSelFocus = ne + 1\n                        state.solfegeInputCursor = (ne + 1 > #buf) and nil or (ne + 1)\n                        state.currentStep = nextIdx\n                        state._solfegeLastCursorActivity = os.clock()\n                    end\n                end\n            elseif key == \"right\" then\n                local oldP = curPos()\n                local lo, hi = solfegeSelRange()\n                if _shiftHeld then\n                    if cur then\n                        local nxt = cur + 1\n                        cur = (nxt > #buf) and nil or nxt\n                    end\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = cur or (#buf + 1)\n                elseif lo then\n                    cur = (hi > #buf) and nil or hi\n                    solfegeClearSel()\n                else\n                    if cur then\n                        local nxt = cur + 1\n                        cur = (nxt > #buf) and nil or nxt\n                    end\n                end\n                state.solfegeInputCursor = cur\n            elseif key == \"home\" then\n                local oldP = curPos()\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = 1\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = 1\n            elseif key == \"end\" then\n                local oldP = curPos()\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = #buf + 1\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = nil\n            elseif _cmdHeld and key == \"up\" then\n                -- Jump to document start (Cmd+Up)\n                state._solfegeStickyX = nil\n                local oldP = curPos()\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = 1\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = 1\n            elseif _cmdHeld and key == \"down\" then\n                -- Jump to document end (Cmd+Down)\n                state._solfegeStickyX = nil\n                local oldP = curPos()\n                if _shiftHeld then\n                    if not state.solfegeSelAnchor then state.solfegeSelAnchor = oldP end\n                    state.solfegeSelFocus = #buf + 1\n                else\n                    solfegeClearSel()\n                end\n                state.solfegeInputCursor = nil\n            elseif key == \"up\" or key == \"down\" then\n                -- In steps mode, try cycling the syllable at the cursor first.\n                -- Up = higher pitch = direction 1; down = lower = direction -1.\n                local cycleDir = (key == \"up\") and 1 or -1\n                if not _stepsSyllableCycleAtCursor(cycleDir, _shiftHeld) then\n                    -- Fall through: deferred to render phase (needs pixel measurements).\n                    state._pendingSolfegeUpDown = { dir = (key == \"up\") and -1 or 1, shift = _shiftHeld }\n                end\n            elseif key == \"space\" and not _cmdHeld then\n                _tryPlaySolfegePreview()  -- play the current token before space ends it\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    buf = state.solfegeInputBuffer or \"\"\n                    cur = state.solfegeInputCursor\n                end\n                if #buf < 2000 then\n                    if cur then\n                        state.solfegeInputBuffer = buf:sub(1, cur - 1) .. \" \" .. buf:sub(cur)\n                        state.solfegeInputCursor = cur + 1\n                    else\n                        state.solfegeInputBuffer = buf .. \" \"\n                    end\n                    state._previewLastNote = nil  -- next token starts fresh\n                    state._previewLastOctave = nil\n                    _requestLiveApply(false)\n                end\n            elseif #key == 1 and not _cmdHeld then\n                _pushSolfegeTextUndoState()\n                if solfegeSelRange() then\n                    solfegeDeleteSel()\n                    buf = state.solfegeInputBuffer or \"\"\n                    cur = state.solfegeInputCursor\n                end\n                if #buf < 2000 then\n                    if cur then\n                        state.solfegeInputBuffer = buf:sub(1, cur - 1) .. key .. buf:sub(cur)\n                        state.solfegeInputCursor = cur + 1\n                    else\n                        state.solfegeInputBuffer = buf .. key\n                    end\n                    _tryPlaySolfegePreview()  -- play if token just became a valid syllable\n                    _requestLiveApply(false)\n                end\n            end\n            return\n        end\n\n        -- Ctrl+Enter: play/pause from anywhere\n        if _cmdHeld and key == \"return\" then\n            if state.isPlaying then pausePlayback() else startPlayback() end\n            return\n        end\n\n        -- Octave shortcuts: [ = octave down, ] = octave up (works in compose mode, including inside dropdown)\n        if not state.singSolfegeMode\n                     and not state.showingModeSelect and not state.showingTemplateBrowser\n           and not state.solfegeInputActive\n           and not showWelcomeScreen then\n            if key == \"[\" then\n                adjustCurrentStepOctave(-1)\n                return\n            elseif key == \"]\" then\n                adjustCurrentStepOctave(1)\n                return\n            end\n        end\n\n        -- ` key: toggle command chat drawer\n        if key == \"`\" and not state.solfegeInputActive\n           and not state.lyricNotesPanelOpen\n           and not state.bpmEditing and not state.lyricEditingStepIndex\n           and not state.musicXMLFilenameEditing then\n            state.cmdChatOpen = not state.cmdChatOpen\n            state.cmdChatInputActive = state.cmdChatOpen\n            if state.cmdChatOpen then state.cmdChatCursorResetTime = os.clock() end\n            if state.cmdChatOpen and #state.cmdChatMessages == 0 then\n                table.insert(state.cmdChatMessages, cmdChat.welcomeMessage())\n            end\n            return\n        end\n\n        -- ? key: toggle keyboard shortcut cheat-sheet overlay\n        if key == \"?\" and not state.solfegeInputActive\n           and not state.lyricNotesPanelOpen\n           and not showWelcomeScreen and not state.showingModeSelect then\n            state.showShortcutHelp = not state.showShortcutHelp\n            return\n        end\n\n        -- In steps mode, keyboard up/down should cycle the current step's solfege syllable\n        -- instead of only being reserved for the detached text editor.\n        if (state.solfegeTextMode or \"both\") == \"steps\"\n           and not state.singSolfegeMode\n                     and not state.showingModeSelect and not state.showingTemplateBrowser\n           and not state.showingSequenceSelect and not state.showingStepSelect\n           and not state.showingMidiInPicker and not state.showingMicInputPicker\n           and not state.showingGamepadPicker and not state.showingMidiControls\n           and not state.bpmEditing and not state.barEditing\n           and not state.beatEditing and not state.seekEditing\n           and not state.solfegeInputActive and not state.lyricEditingStepIndex\n           and not state.lyricNotesPanelOpen and not showWelcomeScreen then\n            if key == \"up\" then\n                if callbacks.onUp then callbacks.onUp() end\n                return\n            elseif key == \"down\" then\n                if callbacks.onDown then callbacks.onDown() end\n                return\n            end\n        end\n\n        -- Home/End: jump to first or last step in compose mode\n        if not state.singSolfegeMode\n                     and not state.showingModeSelect and not state.showingTemplateBrowser\n           and not state.bpmEditing and not state.barEditing\n           and not state.beatEditing and not state.seekEditing\n           and not state.solfegeInputActive\n           and not state.lyricEditingStepIndex and not state.syllableDropdownOpen then\n            if key == \"home\" then\n                state.currentStep = 1\n                local _cs = state.sequence[state.currentStep]\n                if _cs then\n                    if core.isChord(_cs) then\n                        local fn = _cs.notes and _cs.notes[1]\n                        if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n                    elseif _cs.note ~= nil and _cs.note ~= 13 then\n                        state.selectedNote = _cs.note\n                        state.currentOctave = _cs.octave\n                    end\n                    playCurrentStepPreview()\n                end\n                _syncKeyLineInTextBuffer()\n                return\n            elseif key == \"end\" then\n                local target = math.max(1, state.sequenceLength or 0)\n                state.currentStep = target\n                local _cs = state.sequence[state.currentStep]\n                if _cs then\n                    if core.isChord(_cs) then\n                        local fn = _cs.notes and _cs.notes[1]\n                        if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n                    elseif _cs.note ~= nil and _cs.note ~= 13 then\n                        state.selectedNote = _cs.note\n                        state.currentOctave = _cs.octave\n                    end\n                    playCurrentStepPreview()\n                end\n                _syncKeyLineInTextBuffer()\n                return\n            end\n        end\n\n        -- Quick MIDI Learn: press 'm' to map any MIDI button to Play/Stop in one step\n        if key == \"m\" and state.midiInDeviceName ~= \"\"\n           and not state.showingModeSelect and not state.showingMidiControls\n           and not state.isPlaying then\n            if state.midiLearnMode and state.midiLearnTarget == \"play_stop\" then\n                -- Cancel if already learning\n                state.midiLearnMode = false\n                state.midiLearnTarget = nil\n            else\n                state.midiLearnMode = true\n                state.midiLearnTarget = \"play_stop\"\n            end\n            return\n        end\n\n        -- k: play the current key (tonic) note without changing anything\n        if key == \"k\" and not state.isPlaying\n                     and not state.showingModeSelect and not state.showingTemplateBrowser\n           and not showWelcomeScreen then\n            playKeyCenterPreview()\n            return\n        end\n\n        -- Number keys 1-8, 0: enter solfege syllable on current step, then advance to next step\n        -- 1=Do, 2=Re, 3=Mi, 4=Fa, 5=Sol, 6=La, 7=Ti, 8=Do', 0=Rest\n        if not state.showingModeSelect and not state.showingTemplateBrowser\n           and not state.singSolfegeMode and not showWelcomeScreen then\n            local noteForKey = {[\"0\"]=13,[\"1\"]=0,[\"2\"]=2,[\"3\"]=4,[\"4\"]=5,[\"5\"]=7,[\"6\"]=9,[\"7\"]=11,[\"8\"]=12}\n            local noteIdx = noteForKey[key]\n            if noteIdx ~= nil and key ~= state._pendingNumKey then\n                breakEditCursorFollow()\n                state._pendingNumKey = key\n                local targetStep = state.currentStep\n                if applySyllableDropdownChoice(noteIdx) then\n                    -- Restart note with a long sustain; key-up stops it explicitly.\n                    stopVoice(primaryVoice)\n                    local _ks = state.sequence[targetStep]\n                    if _ks and _ks.note ~= nil and _ks.note ~= 13 then\n                        local _kn, _ko = transposeNoteForKey(_ks.note, _ks.octave or state.currentOctave)\n                        local _holdSteps = math.max(2, math.ceil(2000 / math.max(1, (state.stepDuration or 500))))\n                        playNote(_kn, _ko, targetStep, primaryVoice, nil, _holdSteps, 1.0)\n                    end\n                    state._numKeyPressTime = getCurrentTimeMilliseconds()\n                    state._numKeyPressStep = targetStep\n                    state._numKeyPressLen = 1\n                    -- Advance cursor to next step\n                    state.currentStep = state.currentStep + 1\n                    if state.currentStep > state.sequenceLength + 1 then state.currentStep = 1 end\n                    local _cs = state.sequence[state.currentStep]\n                    if not _cs or (not core.isChord(_cs) and _cs.note == nil) then\n                        state.selectedNote = 0\n                    elseif core.isChord(_cs) then\n                        local fn = _cs.notes and _cs.notes[1]\n                        if fn then state.selectedNote = fn.note; state.currentOctave = fn.octave end\n                    elseif _cs.note ~= nil and _cs.note ~= 13 then\n                        state.selectedNote = _cs.note\n                        state.currentOctave = _cs.octave\n                    end\n                end\n                return\n            end\n        end\n\n        if not state.bpmEditing then return end\n        local buf = state.bpmInputBuffer or \"\"\n        local cur = state.bpmInputCursor ~= nil and state.bpmInputCursor or #buf\n        if key == \"escape\" then\n            state.bpmEditing = false\n            state.bpmInputBuffer = nil\n            state.bpmInputCursor = nil\n            state.headerSelection = -1\n        elseif key == \"return\" then\n            local v = tonumber(buf)\n            if v then setTempo(v); savePreferences() end\n            state.bpmEditing = false\n            state.bpmInputBuffer = nil\n            state.bpmInputCursor = nil\n            state.headerSelection = -1\n        elseif key == \"backspace\" then\n            if cur > 0 then\n                state.bpmInputBuffer = buf:sub(1, cur - 1) .. buf:sub(cur + 1)\n                state.bpmInputCursor = cur - 1\n            end\n        elseif key == \"left\" then\n            state.bpmInputCursor = math.max(0, cur - 1)\n        elseif key == \"right\" then\n            state.bpmInputCursor = math.min(#buf, cur + 1)\n        elseif key >= \"0\" and key <= \"9\" then\n            if #buf < 3 then\n                state.bpmInputBuffer = buf:sub(1, cur) .. key .. buf:sub(cur + 1)\n                state.bpmInputCursor = cur + 1\n            end\n        end\n    end\n\n    -- RAW KEY UP handler — used to set step length from hold duration\n    -- Hold thresholds (seconds): tap=1 beat, 0.3s=2 beats, 0.7s=4 beats, 1.4s=8 beats\n    callbacks.onRawKeyUp = function(key)\n        if key == \"left shift\" or key == \"right shift\" then\n            _shiftHeld = false; return\n        end\n        if key == \"left gui\" or key == \"right gui\"\n           or key == \"left command\" or key == \"right command\"\n           or key == \"left ctrl\" or key == \"right ctrl\" then\n            _cmdHeld = false; return\n        end\n        if key == \"left alt\" or key == \"right alt\" then\n            _altHeld = false; return\n        end\n        local noteForKey = {[\"0\"]=13,[\"1\"]=0,[\"2\"]=2,[\"3\"]=4,[\"4\"]=5,[\"5\"]=7,[\"6\"]=9,[\"7\"]=11,[\"8\"]=12}\n        if key == state._pendingNumKey then\n            state._pendingNumKey = nil\n        end\n        if noteForKey[key] ~= nil and state._numKeyPressTime and state._numKeyPressStep then\n            stopVoice(primaryVoice)\n            moveCursorAfterHeldStep(state._numKeyPressStep, state._numKeyPressLen)\n            state._numKeyPressTime = nil\n            state._numKeyPressStep = nil\n            state._numKeyPressLen = nil\n        end\n    end\n\n    -- Secondary OS window closed by the user (e.g. clicking the X button)\n    callbacks.onTextWindowClosed = function()\n        if state.lyricNotesDetached then\n            state.lyricNotesDetached = false\n            return\n        end\n        state.solfegeTextInputSide = \"bottom\"\n        savePreferences()\n    end\n\n    callbacks.onOptionsWindowClosed = function()\n        state._solfegeAllOptionsOpen = false\n    end\n\n    -- Create the input adapter with Playdate implementation\n    inputAdapter = InputAdapter.new(platformAdapters.Input.new(), callbacks)\nend\n\nrefreshSystemMenu()\n\nsystemAdapter:registerLifecycleHandlers({\n    onTerminate = saveAppState,\n    onPause = saveAppState\n})\n\n-- Initialize graphics system\ngfx = GraphicsAdapter.new(platformAdapters.Graphics.new())\n\n-- Hand images are loaded lazily on first use via getHandImage()\nhandImages = {}\n\n-- Try to load saved sequence on startup\nloadPreferences()\nstate.userSolfegeTemplates = state.userSolfegeTemplates or {}\nif state.solfegeScale == \"custom\" and state.customScaleIntervals then\n    _scaleOps.applyCustom(state.customScaleIntervals)\nend\nstate.pendingRootNote = state.rootNote\nstate.keyShift = state.keyShift or 0\n;(function()\n    local host = rawget(_G, \"SDLHost\") or rawget(_G, \"sdlHost\")\n    if host and host.setGamepadEnabled then host.setGamepadEnabled(state.gamepadEnabled ~= false) end\n    if host and host.setSelectedGamepad then\n        local selectedIndex = 0\n        if state.gamepadDeviceName and state.gamepadDeviceName ~= \"\" and host.listGamepads then\n            local devices = host.listGamepads() or {}\n            local i = 1\n            while i <= #devices do\n                if devices[i].name == state.gamepadDeviceName then\n                    selectedIndex = (devices[i].index or 0) + 1\n                    break\n                end\n                i = i + 1\n            end\n        end\n        host.setSelectedGamepad(selectedIndex)\n    end\n\n    -- Steam Deck: auto-enable gamepad and enter fullscreen on first launch\n    if isRunningOnSteamDeck() then\n        if state.gamepadEnabled == nil then\n            state.gamepadEnabled = true\n            if host and host.setGamepadEnabled then host.setGamepadEnabled(true) end\n        end\n        -- Auto-select first available gamepad if none saved\n        if (not state.gamepadDeviceName or state.gamepadDeviceName == \"\") and host and host.listGamepads then\n            local devices = host.listGamepads() or {}\n            if #devices > 0 then\n                local dev = devices[1]\n                state.gamepadEnabled = true\n                state.gamepadDeviceName = dev.name or \"\"\n                if host.setGamepadEnabled then host.setGamepadEnabled(true) end\n                if host.setSelectedGamepad then host.setSelectedGamepad((dev.index or 0) + 1) end\n            end\n        end\n        -- Start in fullscreen (Steam Deck's native resolution)\n        if host and host.setFullscreen then\n            host.setFullscreen(true)\n        end\n    end\n\n    -- Restore saved microphone input device where supported.\n    -- When deferred startup is active, pitchDetector isn't loaded yet so\n    -- listInputDevices() would return empty. Defer to updateFrame() instead.\n    if not rawget(_G, \"_deferredStartupPending\") then\n        if snd and snd.micinput and snd.micinput.setInputDevice then\n            if state.micInputDeviceName and state.micInputDeviceName ~= \"\" and snd.micinput.listInputDevices then\n                local devices = snd.micinput.listInputDevices() or {}\n                local i = 1\n                while i <= #devices do\n                    if devices[i].name == state.micInputDeviceName then\n                        snd.micinput.setInputDevice(devices[i].index)\n                        break\n                    end\n                    i = i + 1\n                end\n            else\n                snd.micinput.setInputDevice(0)\n            end\n        end\n    end\nend)()\n\nsyncPatternSelection()\n;(function()\n    local loadedRecentMusicXML = false\n\n    -- When launched via macOS \"Open With\" / document association, SOLFEGE_OPEN_FILE\n    -- is set by the launcher if the Apple Event arrived before the fork.\n    -- If the event arrived after the fork (race), handleOpenDocuments: writes\n    -- /tmp/solfege_open_with; we check that sentinel here as a fallback.\n    -- The main loop also polls the sentinel for events that arrive even later.\n    -- Stale sentinels from crashed sessions are cleared by the launcher on fresh start.\n    local openWithFile = os.getenv(\"SOLFEGE_OPEN_FILE\")\n    if (not openWithFile or openWithFile == \"\") then\n        local _sf = io.open(\"/tmp/solfege_open_with\", \"r\")\n        if _sf then\n            openWithFile = _sf:read(\"*l\")\n            _sf:close()\n            if openWithFile and openWithFile ~= \"\" then\n                os.remove(\"/tmp/solfege_open_with\")\n            else\n                openWithFile = nil\n            end\n        end\n    end\n    if openWithFile and openWithFile ~= \"\" then\n        local ext = openWithFile:match(\"%.([^%.]+)$\")\n        if ext then ext = ext:lower() end\n        if ext == \"mid\" or ext == \"midi\" then\n            loadedRecentMusicXML = importMidi(openWithFile)\n        elseif ext == \"solfege\" or ext == \"musicxml\" or ext == \"xml\" then\n            loadedRecentMusicXML = importMusicXML(openWithFile)\n        elseif ext == \"docx\" then\n            openDocxAsNewProject(openWithFile)\n            loadedRecentMusicXML = true\n        end\n        if loadedRecentMusicXML then\n            print(\"Opened file from Open With: \" .. tostring(openWithFile))\n        end\n        -- Consumed via env var at startup; remove sentinel so the main-loop\n        -- Open-With handler does not re-import the same file.\n        os.remove(\"/tmp/solfege_open_with\")\n    else\n        -- Show welcome screen; user will choose to open a recent project or create a new one\n        welcomeRecentFiles = {}\n        _welcomeRecentFilesPending = true\n        showWelcomeScreen = true\n        state._welcomeFirstVisible = 1\n        loadedRecentMusicXML = true  -- prevent default template from auto-loading\n    end\n\n    if not loadedRecentMusicXML and not loadSequence() then\n        local defaultTemplate = templateLibrary.getTemplateById(\"major_scale_major_scale_ascending\")\n        if defaultTemplate then\n            templateLibrary.loadTemplate(state, defaultTemplate, core)\n            selectTemplateById(\"major_scale_major_scale_ascending\", \"Major Scales\")\n        end\n    end\nend)()\nresetStepHistory()\n\n-- Reconnect saved MIDI input device after preferences are loaded.\n-- If no device was saved, auto-connect to the first real hardware device (skip IAC/virtual).\n;(function()\n    if midiOut and midiOut.connectInputDevice then\n        local devices = midiOut.getInputDevices and midiOut.getInputDevices() or {}\n        local connected = false\n\n        if state.midiInDeviceName ~= \"\" then\n            -- Try to reconnect to previously selected device by name\n            local matchedDevice = findMidiInputDeviceByName(devices, state.midiInDeviceName)\n            if matchedDevice then\n                local ok = midiOut.connectInputDevice(matchedDevice.index)\n                if ok then\n                    print(\"Reconnected MIDI input: \" .. matchedDevice.name)\n                    connected = true\n                else\n                    print(\"Failed to reconnect MIDI input: \" .. state.midiInDeviceName)\n                end\n            end\n            -- Do NOT clear or overwrite the saved name — device may just not be plugged in yet\n            if not connected then\n                print(\"Saved MIDI device not available yet: \" .. state.midiInDeviceName)\n            end\n        else\n            -- Nothing saved: auto-connect to the first non-virtual hardware device\n            local i = 1\n            while i <= #devices do\n                local name = devices[i].name or \"\"\n                local isVirtual = name:find(\"IAC\") or name:find(\"Bus\") or name:find(\"Virtual\")\n                if not isVirtual then\n                    local ok = midiOut.connectInputDevice(devices[i].index)\n                    if ok then\n                        state.midiInDeviceName = devices[i].name\n                        savePreferences()\n                        print(\"Auto-connected MIDI input: \" .. devices[i].name)\n                    end\n                    break\n                end\n                i = i + 1\n            end\n        end\n    end\nend)()\n\n-- Initialize input system\ninitializeInput()\n\n-- Pre-warm audio cache: generate one note per frame across the first few seconds\n-- so pressing play never triggers cold PCM generation on the main thread.\n-- Uses a global flag (audioPrewarmActive) so updateFrame (defined earlier) can see it.\naudioPrewarmActive = true\n_audioPrewarmQueue = {}\n_audioPrewarmIdx = 1\n_audioPrewarmVoices = {primaryVoice.synth, primaryVoice.synth2}\n;(function()\n    -- Only prewarm the 7 diatonic solfege notes (Do Re Mi Fa Sol La Ti) + octave Do\n    -- across the 3 most-used octaves, with the 3 velocity levels used during playback.\n    -- Chromatic accidentals are excluded — they're rare and can warm on first use.\n    -- With the cache-key fix (effectiveDur), these entries also cover ALL tempos,\n    -- so changing BPM no longer triggers regeneration.\n    local diatonic = {0, 2, 4, 5, 7, 9, 11, 12}  -- Do Re Mi Fa Sol La Ti Do'\n    local octaves = {3, 4, 5}\n    local velocities = {1.0, 0.9, 0.85}\n    local i = 1\n    while i <= #diatonic do\n        if core.noteFreqs[diatonic[i]] then\n            local j = 1\n            while j <= #octaves do\n                local k = 1\n                while k <= #velocities do\n                    table.insert(_audioPrewarmQueue, {note = diatonic[i], octave = octaves[j], velocity = velocities[k]})\n                    k = k + 1\n                end\n                j = j + 1\n            end\n        end\n        i = i + 1\n    end\nend)()\n\nfunction tickAudioPrewarm()\n    if _audioPrewarmIdx > #_audioPrewarmQueue then\n        audioPrewarmActive = false\n        return\n    end\n    -- One item per frame: generate chunk for both synth types without audible playback\n    local item = _audioPrewarmQueue[_audioPrewarmIdx]\n    _audioPrewarmIdx = _audioPrewarmIdx + 1\n    local baseFreq = core.noteFreqs[item.note]\n    if baseFreq then\n        local freq = baseFreq * (2 ^ (item.octave - 4))\n        for _, s in ipairs(_audioPrewarmVoices) do\n            if s.prewarmNote then\n                s:prewarmNote(freq, item.velocity, 0.5)\n            else\n                s:playNote(freq, item.velocity, 0.5)\n                s:stop()\n            end\n        end\n    end\nend\n\n_G.importMidi = importMidi\n_G.importMusicXML = importMusicXML\n_G.openDocxAsNewProject = openDocxAsNewProject\n\nfunction playCmdChatSolfegeText(text, loop, force)\n    state._playbackEndingGrace = false\n    if state.audioMuted then\n        return false, \"Audio muted\"\n    end\n    if state.cmdChatMuted and not force then\n        stopCmdChatSolfegeLoop()\n        if stopAllVoices then stopAllVoices() end\n        return false, \"Chat muted\"\n    end\n    local queue = {}\n    for token in tostring(text or \"\"):gmatch(\"%S+\") do\n        local parsed = parseSolfegeToken(token)\n        if parsed and parsed.note ~= 13 then\n            local octave = parsed.octave or state.currentOctave or state.keyOctave or 4\n            local note, noteOctave = transposeNoteForKey(parsed.note, octave)\n            queue[#queue + 1] = {\n                note = note,\n                octave = noteOctave,\n                length = parsed.length or math.min(core.getStepLength(state.sequence[state.currentStep] or {}) or 1, 1),\n            }\n        end\n    end\n    state._cmdChatAuditionLoopText = (loop and #queue > 0) and text or nil\n    state._cmdChatAuditionQueue = queue\n    state._cmdChatAuditionIndex = 1\n    state._cmdChatAuditionNextAt = nil\n    state._cmdChatAuditionForce = force == true\n    state._cmdChatAuditionPaused = false\n    state._cmdChatAuditionRemainingMs = nil\n\n    if force and #queue > 0 then\n        local item = queue[1]\n        playNote(item.note, item.octave, nil, primaryVoice, nil, item.length, 0.85)\n        state._cmdChatAuditionIndex = 2\n        state._cmdChatAuditionNextAt = getCurrentTimeMilliseconds() + math.max(120, math.min(350, math.floor((state.stepDuration or 500) * 0.45)))\n    end\n    return #queue > 0, (#queue > 0) and nil or \"Nothing to play\"\nend\n\nfunction loopCmdChatSolfegeText(text, force)\n    return playCmdChatSolfegeText(text, true, force == true)\nend\n\nfunction stopCmdChatSolfegeLoop()\n    local hadLoop = state._cmdChatAuditionLoopText ~= nil\n        or state._cmdChatAuditionQueue ~= nil\n    state._cmdChatAuditionLoopText = nil\n    state._cmdChatAuditionQueue = nil\n    state._cmdChatAuditionIndex = nil\n    state._cmdChatAuditionNextAt = nil\n    state._cmdChatAuditionForce = nil\n    state._cmdChatAuditionPaused = nil\n    state._cmdChatAuditionRemainingMs = nil\n    return hadLoop\nend\n\nfunction pauseCmdChatSolfegeLoop()\n    local queue = state._cmdChatAuditionQueue\n    local index = state._cmdChatAuditionIndex or 1\n    if not queue or index > #queue or state._cmdChatAuditionPaused then\n        return false\n    end\n    local now = getCurrentTimeMilliseconds()\n    local nextAt = state._cmdChatAuditionNextAt\n    state._cmdChatAuditionRemainingMs = nextAt and math.max(0, nextAt - now) or 0\n    state._cmdChatAuditionPaused = true\n    if stopAllVoices then stopAllVoices() end\n    return true\nend\n\nfunction resumeCmdChatSolfegeLoop()\n    local queue = state._cmdChatAuditionQueue\n    local index = state._cmdChatAuditionIndex or 1\n    if not queue or index > #queue or not state._cmdChatAuditionPaused then\n        return false\n    end\n    local remainingMs = state._cmdChatAuditionRemainingMs or 0\n    state._cmdChatAuditionPaused = false\n    state._cmdChatAuditionRemainingMs = nil\n    state._cmdChatAuditionNextAt = getCurrentTimeMilliseconds() + remainingMs\n    return true\nend\n\nfunction setCmdChatMuted(muted)\n    state.cmdChatMuted = muted == true\n    if state.cmdChatMuted then\n        stopCmdChatSolfegeLoop()\n        if stopAllVoices then stopAllVoices() end\n        if stopDrone then stopDrone() end\n    end\n    savePreferences()\nend\n\nfunction getCmdChatSolfegeTemplateCategories()\n    return ui.SOLFEGE_TEMPLATE_CATEGORIES or {}\nend\n\nfunction setCmdChatSolfegeText(text)\n    state.solfegeTextMode = \"steps\"\n    state.showSolfegeTextInput = true\n    if not text or text == \"\" then\n        clearCmdChatSolfegeText()\n        clearSequence()\n        core.syncActiveSequenceState(state)\n        return\n    end\n    local prevReplaceMode = state._solfegeTemplateReplaceMode\n    state._solfegeTemplateReplaceMode = true\n    insertSolfegeTemplateText(text)\n    state._solfegeTemplateReplaceMode = prevReplaceMode\n    state._solfegeSeqText = state.solfegeInputBuffer or text\n    liveApplySequenceFromText()\n    core.syncActiveSequenceState(state)\n    markSequenceDirty()\nend\n\nfunction addCmdChatSolfegeText(text)\n    state.solfegeTextMode = \"steps\"\n    state.showSolfegeTextInput = true\n    -- Preserve loopPlayback across the serialize→parse round-trip so the\n    -- Loop:Off header (emitted when loopPlayback is false by default) doesn't\n    -- kill an active audition loop.\n    local savedLoop = state.loopPlayback\n    local base = serializeSequenceToText()\n    state.solfegeInputBuffer = base\n    state._solfegeSeqText = base\n    insertSolfegeTemplateText(text)\n    liveApplySequenceFromText()\n    state.loopPlayback = savedLoop\n    core.syncActiveSequenceState(state)\n    markSequenceDirty()\nend\n\nfunction getCmdChatSolfegeText()\n    if (state.solfegeTextMode or \"steps\") == \"steps\"\n       and state.showSolfegeTextInput ~= false\n       and type(state.solfegeInputBuffer) == \"string\"\n       and state.solfegeInputBuffer ~= \"\" then\n        return state.solfegeInputBuffer\n    end\n    local savedMode = state.solfegeTextMode\n    state.solfegeTextMode = \"steps\"\n    local text = serializeSequenceToText()\n    state.solfegeTextMode = savedMode\n    return text\nend\n\nfunction cmdChatBufferLooksLikeSolfege(text)\n    local sawPlayable = false\n    for raw in tostring(text or \"\"):gmatch(\"%S+\") do\n        local token = raw:gsub(\"^%[\", \"\"):gsub(\"%]$\", \"\")\n        local lower = token:lower()\n        if token == \"|\" or token == \"||\" then\n            -- layout marker\n        elseif lower:match(\"^key:\") or lower:match(\"^scale:\") or lower:match(\"^bpm:\")\n            or lower:match(\"^meter:\") or lower:match(\"^loop:\") or lower:match(\"^octave:\")\n            or lower:match(\"^t:\") or lower:match(\"^transpose:\") or lower:match(\"^length:\")\n            or lower:match(\"^oct:\") or lower:match(\"^default:\") then\n            -- editable header/meta token\n        elseif parseSolfegeToken(token) then\n            sawPlayable = true\n        else\n            return false\n        end\n    end\n    return sawPlayable\nend\n\nfunction startCmdChatPlayback()\n    local buf = tostring(state.solfegeInputBuffer or \"\"):match(\"^%s*(.-)%s*$\")\n    local shouldApplyTextBuffer = state.showSolfegeTextInput ~= false or state.solfegeInputActive == true\n    local hadTextBuffer = shouldApplyTextBuffer and buf ~= \"\"\n    if hadTextBuffer then\n        local ok, err = playCmdChatSolfegeText(buf, state.loopPlayback == true, true)\n        if ok == true then\n            return true\n        elseif err == \"Audio muted\" then\n            return false, err\n        end\n    end\n    if shouldApplyTextBuffer and buf ~= \"\" then\n        local savedMode = state.solfegeTextMode\n        if savedMode == \"lyrics\" then\n            state.solfegeTextMode = \"steps\"\n        end\n        liveApplySequenceFromText()\n        state.solfegeTextMode = savedMode\n    end\n    core.syncActiveSequenceState(state)\n    if getPlaybackLength() == 0 then\n        if hadTextBuffer and cmdChatBufferLooksLikeSolfege(buf) then\n            local ok, err = playCmdChatSolfegeText(buf, state.loopPlayback == true, true)\n            return ok == true, err\n        end\n        return false\n    end\n    startPlayback()\n    return state.isPlaying == true\nend\n\nfunction setCmdChatLyricsText(text)\n    state.solfegeTextMode = \"lyrics\"\n    state.showSolfegeTextInput = true\n    state.solfegeInputBuffer = tostring(text or \"\")\n    state._solfegeSeqText = state.solfegeInputBuffer\n    state.solfegeInputActive = true\n    liveApplySequenceFromText()\n    markSequenceDirty()\n    savePreferences()\nend\n\nfunction addCmdChatLyricsText(text)\n    local nextText = tostring(text or \"\")\n    if nextText == \"\" then return end\n    state.solfegeTextMode = \"lyrics\"\n    state.showSolfegeTextInput = true\n    local buf = state.solfegeInputBuffer or state._solfegeSeqText or serializeSequenceToText() or \"\"\n    local sep = (buf ~= \"\" and not buf:match(\"%s$\")) and \" \" or \"\"\n    state.solfegeInputBuffer = buf .. sep .. nextText\n    state._solfegeSeqText = state.solfegeInputBuffer\n    state.solfegeInputActive = true\n    liveApplySequenceFromText()\n    markSequenceDirty()\n    savePreferences()\nend\n\nfunction setCmdChatLyricNotesText(text)\n    state.lyricNotesBuffer = tostring(text or \"\")\n    state.lyricNotesPanelOpen = true\n    state.lyricNotesInputActive = true\n    savePreferences()\nend\n\nfunction importCmdChatLyricsText(text)\n    local ok = importLyricNotesText(tostring(text or \"\"))\n    if ok then\n        savePreferences()\n    end\n    return ok\nend\n\nfunction clearCmdChatSolfegeText()\n    state.solfegeInputBuffer = \"\"\n    state._solfegeSeqText = \"\"\n    state.solfegeInputCursor = nil\n    state.solfegeSelAnchor = nil\n    state.solfegeSelFocus = nil\n    markSequenceDirty()\n    savePreferences()\nend\n\nfunction clearCmdChatLyrics()\n    for i = 1, state.sequenceLength or 0 do\n        if state.sequence[i] then\n            state.sequence[i].lyric = nil\n            state.sequence[i].paragraphEnd = nil\n        end\n    end\n    if state.solfegeTextMode == \"lyrics\" then\n        state.solfegeInputBuffer = \"\"\n        state._solfegeSeqText = \"\"\n    end\n    state.lyricNotesBuffer = \"\"\n    markSequenceDirty()\n    savePreferences()\nend\n\nfunction clearCmdChatAllTextLyricsAndSequence()\n    clearSequence()\n    core.syncActiveSequenceState(state)\n    clearCmdChatSolfegeText()\n    clearCmdChatLyrics()\n    state.currentStep = 1\n    markSequenceDirty()\n    savePreferences()\nend\n\nfunction undoCmdChatEdit()\n    if state.solfegeInputActive then\n        undoSolfegeTextEdit()\n        return \"Text undo\"\n    end\n    if undoStepChange() then\n        return \"Step undo\"\n    end\n    return nil\nend\n\nfunction redoCmdChatEdit()\n    if state.solfegeInputActive then\n        redoSolfegeTextEdit()\n        return \"Text redo\"\n    end\n    if redoStepChange() then\n        return \"Step redo\"\n    end\n    return nil\nend\n\nfunction tickCmdChatAudition()\n    if state.isPlaying or state._playbackEndingGrace then\n        return\n    end\n    if state.cmdChatMuted and not state._cmdChatAuditionForce then\n        stopCmdChatSolfegeLoop()\n        if stopAllVoices then stopAllVoices() end\n        return\n    end\n    if state._cmdChatAuditionPaused then\n        return\n    end\n    local queue = state._cmdChatAuditionQueue\n    local index = state._cmdChatAuditionIndex or 1\n    if not queue or index > #queue then\n        local loopText = state._cmdChatAuditionLoopText\n        local loopForce = state._cmdChatAuditionForce == true\n        state._cmdChatAuditionQueue = nil\n        state._cmdChatAuditionIndex = nil\n        state._cmdChatAuditionNextAt = nil\n        if loopText then\n            playCmdChatSolfegeText(loopText, true, false)\n            state._cmdChatAuditionForce = loopForce\n            state._cmdChatAuditionNextAt = getCurrentTimeMilliseconds() + math.max(140, math.min(400, math.floor((state.stepDuration or 500) * 0.6)))\n        end\n        return\n    end\n\n    local now = getCurrentTimeMilliseconds()\n    if state._cmdChatAuditionNextAt and now < state._cmdChatAuditionNextAt then\n        return\n    end\n\n    local item = queue[index]\n    playNote(item.note, item.octave, nil, primaryVoice, nil, item.length, 0.85)\n    state._cmdChatAuditionIndex = index + 1\n    state._cmdChatAuditionNextAt = now + math.max(120, math.min(350, math.floor((state.stepDuration or 500) * 0.45)))\nend\n\ncmdChat.init(state, core, templateLibrary, {\n    setTempo = setTempo,\n    startPlayback = startCmdChatPlayback,\n    stopPlayback = stopPlayback,\n    pausePlayback = pausePlayback,\n    clearSequence = clearSequence,\n    setStepLength = setStepBeats,\n    undoEdit = undoCmdChatEdit,\n    redoEdit = redoCmdChatEdit,\n    savePreferences = savePreferences,\n    renameProject = renameCmdChatProject,\n    addSolfegeText = addCmdChatSolfegeText,\n    getSolfegeText = getCmdChatSolfegeText,\n    setSolfegeText = setCmdChatSolfegeText,\n    saveSolfegeMelody = saveSolfegeMelodyTemplate,\n    setLyricsText = setCmdChatLyricsText,\n    addLyricsText = addCmdChatLyricsText,\n    setLyricNotesText = setCmdChatLyricNotesText,\n    importLyricsText = importCmdChatLyricsText,\n    clearSolfegeText = clearCmdChatSolfegeText,\n    clearLyrics = clearCmdChatLyrics,\n    clearAll = clearCmdChatAllTextLyricsAndSequence,\n    playSolfegeText = playCmdChatSolfegeText,\n    loopSolfegeText = loopCmdChatSolfegeText,\n    stopSolfegeLoop = stopCmdChatSolfegeLoop,\n    pauseSolfegeLoop = pauseCmdChatSolfegeLoop,\n    resumeSolfegeLoop = resumeCmdChatSolfegeLoop,\n    setChatMuted = setCmdChatMuted,\n    setMasterVolume = function(volume)\n        setMasterVolume(volume, true)\n    end,\n    shiftPatternOctave = function(delta)\n        local idx = state.activeSequenceIndex or 1\n        state.sequenceOctaveTranspose = state.sequenceOctaveTranspose or {}\n        local current = state.sequenceOctaveTranspose[idx] or 0\n        local nextValue = math.max(-3, math.min(3, current + (tonumber(delta) or 0)))\n        state.sequenceOctaveTranspose[idx] = nextValue\n        markSequenceDirty()\n        return nextValue\n    end,\n    setDrone = function(opts)\n        opts = opts or {}\n        if opts.note ~= nil then\n            state.droneNoteSelection = math.min(12, math.max(0, math.floor(opts.note)))\n        end\n        if opts.octave ~= nil then\n            state.droneOctave = math.min(7, math.max(2, math.floor(opts.octave)))\n        end\n        setDroneEnabled(opts.enabled == true)\n    end,\n    getSolfegeTemplateCategories = getCmdChatSolfegeTemplateCategories,\n    onLoopPlaybackChanged = function()\n        if _syncKeyLineInTextBuffer then _syncKeyLineInTextBuffer() end\n        if refreshSystemMenu then refreshSystemMenu() end\n    end,\n    onTemplateLoaded = function()\n        if refreshSolfegeNotes then refreshSolfegeNotes() end\n    end,\n    setScale = function(mode)\n        local oldScale = state.solfegeScale or \"major\"\n        state.solfegeScale = mode\n        state.scaleMode = mode\n        _scaleOps.remapNotesForScaleChange(oldScale, mode)\n        refreshSolfegeNotes()\n        snapKeyNoteToScale()\n        markSequenceDirty()\n        savePreferences()\n    end,\n    startEarTraining = function(exerciseType, difficulty)\n        if difficulty then\n            state.earTrainingDifficulty = difficulty\n        end\n        core._et.start(exerciseType)\n    end,\n    toggleSoundMode = toggleSoundMode,\n    setAcapellaMode = function(enabled)\n        setAcapellaMode(enabled)\n    end,\n    setStreamAudioEnabled = function(enabled)\n        setStreamAudioEnabled(enabled, true)\n    end,\n    setSoundPreview = function(enabled)\n        state.soundPreviewEnabled = enabled\n        savePreferences()\n    end,\n    setStepPreview = function(enabled)\n        state.soundPreviewOnNavigation = enabled\n        savePreferences()\n    end,\n    setDefaultMode = function(sing)\n        state.defaultSingSolfegeMode = sing\n        savePreferences()\n    end,\n    setRandomizeRoot = function(enabled)\n        state.randomizeRootPlayback = enabled\n        savePreferences()\n        if state.isPlaying then setPlaybackRootNote() end\n    end,\n    setRandomizeOctave = function(enabled)\n        state.randomizeOctavePlayback = enabled\n        savePreferences()\n    end,\n    setHideNoteLabels = function(enabled)\n        state.hideNoteNamesDuringPlayback = enabled\n        savePreferences()\n    end,\n    setRevealHeardNotes = function(enabled)\n        state.earTrainingRevealAfterPlayback = enabled\n        savePreferences()\n    end,\n    setHideNotesSing = function(enabled)\n        state.hideNoteNamesDuringSing = enabled\n        savePreferences()\n    end,\n    setShowHands = function(enabled)\n        state.showHandsDuringPlayback = enabled\n        savePreferences()\n    end,\n    setPlayKeyBeforeSteps = function(enabled)\n        state.playKeyBeforeSteps = enabled\n        savePreferences()\n    end,\n    setKeyLeadIn = function(beats)\n        state.keyLeadInBeats = math.max(1, math.min(4, beats))\n        savePreferences()\n    end,\n    setKeyNote = function(noteIndex)\n        state.keyNote = math.max(0, math.min(11, noteIndex))\n        snapKeyNoteToScale()\n        savePreferences()\n    end,\n    setKeyOctave = function(octave)\n        state.keyOctave = math.max(2, math.min(7, octave))\n        savePreferences()\n    end,\n    setPlaybackStop = function(seconds)\n        state.playbackStopSeconds = seconds\n        savePreferences()\n        if state.isPlaying then schedulePlaybackStopTimer() end\n    end,\n    resetPreferences = resetPreferences,\n    getPatternTypes = function()\n        return core.patternTypes\n    end,\n    getPatternList = function(typeIndex)\n        local saved = state.selectedPatternType\n        state.selectedPatternType = typeIndex\n        local list = getCurrentPatternList()\n        state.selectedPatternType = saved\n        return list\n    end,\n    loadPattern = function(patternIndex, typeIndex, octave)\n        if typeIndex then state.selectedPatternType = typeIndex end\n        if octave then state.patternOctave = octave end\n        state.patternTargetSequenceIndex = state.activeSequenceIndex\n        loadPattern(patternIndex)\n    end,\n    showRecordingUI = function()\n        showRecordingUI()\n    end,\n    exportMusicXML = function()\n        local xml = MusicXML.exportStateToString(state, core)\n        if not xml then return nil, \"Failed to generate MusicXML\" end\n        local name = state.musicXMLFileName or \"export\"\n        name = name:gsub(\"%.solfege$\", \"\"):gsub(\"%.musicxml$\", \"\")\n        if name == \"\" then name = \"export\" end\n        return name .. \".musicxml\", xml\n    end,\n})\nmessageBridge.init(cmdChat, state)\nif #state.cmdChatMessages == 0 then\n    table.insert(state.cmdChatMessages, cmdChat.welcomeMessage())\nend\nstate.cmdChatInputActive = state.cmdChatOpen\n\n-- Test hooks (headless integration tests only)\n_G._testCreateNewProject = createNewProject\n_G._testState = state\n","message_bridge.lua":"local messageBridge = {}\nlocal _cmdChat\nlocal _state\nlocal _pollInterval = 30\nlocal _frameCounter = 0\nlocal _inboxPath\nlocal _outboxPath\nlocal _dirCreated = false\n\nfunction messageBridge.init(cmdChat, state, opts)\n    _cmdChat = cmdChat\n    _state = state\n    opts = opts or {}\n    _pollInterval = opts.pollInterval or 30\n    local home = os.getenv(\"HOME\") or \"/tmp\"\n    local dir = opts.dir or (home .. \"/.solfege\")\n    _inboxPath = dir .. \"/inbox.txt\"\n    _outboxPath = dir .. \"/outbox.txt\"\nend\n\nfunction messageBridge.tick()\n    _frameCounter = _frameCounter + 1\n    if _frameCounter < _pollInterval then return end\n    _frameCounter = 0\n    messageBridge._poll()\nend\n\nfunction messageBridge._ensureDir()\n    if _dirCreated then return end\n    local dir = _inboxPath:match(\"^(.*)/[^/]+$\")\n    if dir then os.execute(\"mkdir -p '\" .. dir .. \"'\") end\n    _dirCreated = true\nend\n\nfunction messageBridge._poll()\n    messageBridge._ensureDir()\n\n    local f = io.open(_inboxPath, \"r\")\n    if not f then return end\n\n    local content = f:read(\"*a\")\n    f:close()\n\n    f = io.open(_inboxPath, \"w\")\n    if f then f:close() end\n\n    if not content or content:match(\"^%s*$\") then return end\n\n    local responses = {}\n    local timestamp = os.date(\"%Y-%m-%d %H:%M:%S\")\n    local timeShort = os.date(\"%H:%M:%S\")\n\n    for line in content:gmatch(\"[^\\r\\n]+\") do\n        local trimmed = line:match(\"^%s*(.-)%s*$\")\n        if trimmed ~= \"\" and trimmed:sub(1, 1) ~= \"#\" then\n            local response = _cmdChat.execute(trimmed)\n            local text = \"OK\"\n            if response and response.text then\n                text = response.text\n            elseif type(response) == \"string\" then\n                text = response\n            end\n            _state.cmdChatMessages = _state.cmdChatMessages or {}\n            table.insert(_state.cmdChatMessages, {role = \"user\", text = \"[bridge] \" .. trimmed})\n            if response then\n                table.insert(_state.cmdChatMessages, response)\n            end\n            while #_state.cmdChatMessages > 100 do\n                table.remove(_state.cmdChatMessages, 1)\n            end\n            responses[#responses + 1] = string.format(\"[%s] %s\", timeShort, text)\n        end\n    end\n\n    if #responses > 0 then\n        local out = io.open(_outboxPath, \"a\")\n        if out then\n            out:write(string.format(\"--- %s ---\\n\", timestamp))\n            for _, r in ipairs(responses) do\n                out:write(r .. \"\\n\")\n            end\n            out:close()\n        end\n    end\nend\n\nreturn messageBridge\n","midi_import.lua":"local MidiImport = {}\n\nlocal function readUint16(data, index)\n    local b1, b2 = string.byte(data, index, index + 1)\n    return (b1 * 256) + b2\nend\n\nlocal function readUint32(data, index)\n    local b1, b2, b3, b4 = string.byte(data, index, index + 3)\n    return ((b1 * 256 + b2) * 256 + b3) * 256 + b4\nend\n\nlocal function readVarLen(data, index)\n    local value = 0\n    local byte\n    repeat\n        byte = string.byte(data, index)\n        index = index + 1\n        value = (value * 128) + (byte % 128)\n    until byte < 128\n    return value, index\nend\n\nlocal function readBinaryFile(readBinary, path)\n    if readBinary then\n        local data, errorMessage = readBinary(path)\n        if data ~= nil then\n            return data\n        end\n        if errorMessage then\n            return nil, errorMessage\n        end\n    end\n\n    local file = io.open(path, \"rb\")\n    if not file then\n        return nil, \"Could not open MIDI file: \" .. path\n    end\n    local data = file:read(\"*a\")\n    file:close()\n    return data\nend\n\nlocal function noteNumberToSolfege(noteNumber)\n    local noteIndex = noteNumber % 12\n    local octave = math.floor(noteNumber / 12) - 1\n    return noteIndex, octave\nend\n\nlocal function addNoteToStep(sequence, stepIndex, noteIndex, octave)\n    local step = sequence[stepIndex]\n    if not step then\n        sequence[stepIndex] = {note = noteIndex, octave = octave}\n        return\n    end\n    if step.notes then\n        table.insert(step.notes, {note = noteIndex, octave = octave})\n        return\n    end\n    sequence[stepIndex] = {\n        notes = {\n            {note = step.note, octave = step.octave},\n            {note = noteIndex, octave = octave}\n        }\n    }\nend\n\nlocal function importMidiData(state, core, data)\n    local index = 1\n    if data:sub(index, index + 3) ~= \"MThd\" then\n        return false, \"Missing MIDI header\"\n    end\n    index = index + 4\n    local headerLength = readUint32(data, index)\n    index = index + 4\n    if headerLength < 6 then\n        return false, \"Invalid MIDI header length\"\n    end\n    local format = readUint16(data, index)\n    index = index + 2\n    local tracks = readUint16(data, index)\n    index = index + 2\n    local division = readUint16(data, index)\n    index = index + 2\n    index = index + (headerLength - 6)\n\n    if division >= 0x8000 then\n        return false, \"SMPTE time division not supported\"\n    end\n    if format > 1 then\n        return false, \"Only MIDI format 0/1 supported\"\n    end\n\n    local ticksPerStep = math.max(1, math.floor(division * (state.stepBeats or 1) + 0.5))\n    local importedNotes = 0\n    local channelLastTick = {}\n\n    for trackIndex = 1, tracks do\n        if data:sub(index, index + 3) ~= \"MTrk\" then\n            return false, \"Missing track header\"\n        end\n        index = index + 4\n        local trackLength = readUint32(data, index)\n        index = index + 4\n        local trackEnd = index + trackLength - 1\n        local runningStatus = nil\n        local tick = 0\n\n        while index <= trackEnd do\n            local delta\n            delta, index = readVarLen(data, index)\n            tick = tick + delta\n            local status = string.byte(data, index)\n            if status < 0x80 then\n                if not runningStatus then\n                    return false, \"Running status without prior status\"\n                end\n                status = runningStatus\n            else\n                index = index + 1\n                if status < 0xF0 then\n                    runningStatus = status\n                end\n            end\n\n            local eventType = status & 0xF0\n            local channel = status & 0x0F\n\n            if status == 0xFF then\n                local metaType = string.byte(data, index)\n                index = index + 1\n                local length\n                length, index = readVarLen(data, index)\n                index = index + length\n            elseif status == 0xF0 or status == 0xF7 then\n                local length\n                length, index = readVarLen(data, index)\n                index = index + length\n            elseif eventType == 0x80 or eventType == 0x90 then\n                local noteNumber = string.byte(data, index)\n                index = index + 1\n                local velocity = string.byte(data, index)\n                index = index + 1\n\n                if eventType == 0x90 and velocity > 0 then\n                    local stepIndex = math.floor(tick / ticksPerStep) + 1\n                    if stepIndex <= core.maxSteps then\n                        local sequenceIndex = channel + 1\n                        if sequenceIndex <= core.maxSequences then\n                            local noteIndex, octave = noteNumberToSolfege(noteNumber)\n                            octave = math.min(math.max(2, octave), 7)\n                            addNoteToStep(state.sequences[sequenceIndex], stepIndex, noteIndex, octave)\n                            if stepIndex > state.sequenceLengths[sequenceIndex] then\n                                state.sequenceLengths[sequenceIndex] = stepIndex\n                            end\n                            importedNotes = importedNotes + 1\n                        end\n                    end\n                end\n                local sequenceIndex = channel + 1\n                if sequenceIndex <= core.maxSequences then\n                    if not channelLastTick[sequenceIndex] or tick > channelLastTick[sequenceIndex] then\n                        channelLastTick[sequenceIndex] = tick\n                    end\n                end\n            else\n                local dataLength = (eventType == 0xC0 or eventType == 0xD0) and 1 or 2\n                index = index + dataLength\n            end\n        end\n    end\n\n    for sequenceIndex, lastTick in pairs(channelLastTick) do\n        local stepIndex = math.floor(lastTick / ticksPerStep) + 1\n        stepIndex = math.min(stepIndex, core.maxSteps)\n        if stepIndex > state.sequenceLengths[sequenceIndex] then\n            state.sequenceLengths[sequenceIndex] = stepIndex\n        end\n    end\n\n    return true, importedNotes\nend\n\nfunction MidiImport.importMidiFile(state, core, filename, readBinary)\n    local data, errorMessage = readBinaryFile(readBinary, filename)\n    if not data then\n        return false, errorMessage\n    end\n\n    for i = 1, core.maxSequences do\n        state.sequences[i] = {}\n        state.sequenceLengths[i] = 0\n    end\n\n    local ok, result = importMidiData(state, core, data)\n    if not ok then\n        return false, result\n    end\n\n    core.setActiveSequence(state, 1, true)\n    state.sequence = state.sequences[state.activeSequenceIndex]\n    state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\n    state.currentStep = 1\n\n    return true, result\nend\n\nreturn MidiImport\n","musicxml.lua":"local MusicXML = {}\n\nlocal function xmlEscape(value)\n    local text = tostring(value or \"\")\n    text = text:gsub(\"&\", \"&amp;\")\n    text = text:gsub(\"<\", \"&lt;\")\n    text = text:gsub(\">\", \"&gt;\")\n    text = text:gsub('\"', \"&quot;\")\n    text = text:gsub(\"'\", \"&apos;\")\n    return text\nend\n\nlocal function xmlUnescape(value)\n    if type(value) ~= \"string\" then\n        return value\n    end\n    local text = value\n    text = text:gsub(\"&lt;\", \"<\")\n    text = text:gsub(\"&gt;\", \">\")\n    text = text:gsub(\"&quot;\", '\"')\n    text = text:gsub(\"&apos;\", \"'\")\n    text = text:gsub(\"&amp;\", \"&\")\n    return text\nend\n\nlocal function parseNumber(value)\n    local parsed = tonumber(value)\n    return parsed\nend\n\nlocal function deepCopy(value)\n    if type(value) ~= \"table\" then\n        return value\n    end\n\n    local copy = {}\n    for key, nestedValue in pairs(value) do\n        copy[key] = deepCopy(nestedValue)\n    end\n    return copy\nend\n\nlocal function buildMetadataFields(state)\n    local fields = {\n        tempo = state.tempo,\n        stepBeats = state.stepBeats,\n        meterNumerator = state.meterNumerator,\n        meterDenominator = state.meterDenominator,\n        activeSequenceIndex = state.activeSequenceIndex,\n        selectedNote = state.selectedNote,\n        currentOctave = state.currentOctave,\n        defaultTempo = state.defaultTempo,\n        showHandsDuringPlayback = state.showHandsDuringPlayback,\n        soundPreviewEnabled = state.soundPreviewEnabled,\n        acapellaMode = state.acapellaMode,\n        darkMode = state.darkMode,\n        pitchRecognitionEnabled = state.pitchRecognitionEnabled,\n        playKeyBeforeSteps = state.playKeyBeforeSteps,\n        keyLeadInBeats = state.keyLeadInBeats,\n        rootNote = state.rootNote,\n        keyNote = state.keyNote,\n        keyOctave = state.keyOctave,\n        randomizeRootPlayback = state.randomizeRootPlayback,\n        randomizeOctavePlayback = state.randomizeOctavePlayback,\n        hideNoteNamesDuringPlayback = state.hideNoteNamesDuringPlayback,\n        useShapeNotes = state.useShapeNotes,\n        playbackStopSeconds = state.playbackStopSeconds,\n        droneEnabled = state.droneEnabled,\n        droneNoteSelection = state.droneNoteSelection,\n        droneOctave = state.droneOctave,\n        selectedPatternType = state.selectedPatternType,\n        selectedPattern = state.selectedPattern,\n        patternTargetSequenceIndex = state.patternTargetSequenceIndex,\n        patternOctave = state.patternOctave,\n        sidebarOpen = state.sidebarOpen,\n        singSolfegeMode = state.singSolfegeMode,\n        solfegeScale = state.solfegeScale,\n        showLyrics = state.showLyrics,\n        showSolfegeLyrics = state.showSolfegeLyrics,\n        hideSteps = state.hideSteps,\n        masterVolume = state.masterVolume,\n        rowBreakAfterStep = state.rowBreakAfterStep,\n        lyricNotesBuffer = state.lyricNotesBuffer,\n        solfegeInputBuffer = state.solfegeInputBuffer,\n        solfegeTextMode = state.solfegeTextMode,\n        linkedLyricsDocxPath = state.linkedLyricsDocxPath\n    }\n\n    if state.selectedPatternByType then\n        local values = {}\n        for i, value in ipairs(state.selectedPatternByType) do\n            values[i] = tostring(value)\n        end\n        fields.selectedPatternByType = table.concat(values, \",\")\n    end\n\n    if state.sequenceMutes then\n        local mutes = {}\n        for i = 1, #state.sequenceMutes do\n            mutes[i] = state.sequenceMutes[i] and \"1\" or \"0\"\n        end\n        fields.sequenceMutes = table.concat(mutes, \",\")\n    end\n\n    if state.sequenceOctaveTranspose then\n        local transpose = {}\n        for i = 1, #state.sequenceOctaveTranspose do\n            transpose[i] = tostring(state.sequenceOctaveTranspose[i] or 0)\n        end\n        fields.sequenceOctaveTranspose = table.concat(transpose, \",\")\n    end\n\n    return fields\nend\n\nlocal function toPitch(pitchClass, pitchOctave)\n    local note = pitchClass or 13\n    if note == 13 then\n        return nil\n    end\n\n    local pitchStepNames = {\"C\", \"C\", \"D\", \"D\", \"E\", \"F\", \"F\", \"G\", \"G\", \"A\", \"A\", \"B\", \"C\"}\n    local pitchAlterations = {0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0}\n\n    return {\n        step = pitchStepNames[note + 1],\n        alter = pitchAlterations[note + 1],\n        octave = pitchOctave or 4\n    }\nend\n\nlocal function parsePitch(noteXml)\n    if noteXml:find(\"<rest[%s>][^>]-/>\") or noteXml:find(\"<rest[%s>][^>]*>\") then\n        return 13, nil\n    end\n\n    local pitchBlock = noteXml:match(\"<pitch[^>]*>(.-)</pitch>\")\n    if not pitchBlock then\n        return nil, nil\n    end\n\n    local step = pitchBlock:match(\"<step[^>]*>(.-)</step>\")\n    local alter = parseNumber(pitchBlock:match(\"<alter[^>]*>(.-)</alter>\")) or 0\n    local octave = parseNumber(pitchBlock:match(\"<octave[^>]*>(.-)</octave>\"))\n    if not step or not octave then\n        return nil, nil\n    end\n\n    local baseMap = {\n        C = 0,\n        D = 2,\n        E = 4,\n        F = 5,\n        G = 7,\n        A = 9,\n        B = 11\n    }\n\n    local base = baseMap[step]\n    if base == nil then\n        return nil, nil\n    end\n\n    local note = (base + alter) % 12\n    return note, octave\nend\n\nlocal function parseLyric(noteXml)\n    local lyricCandidates = {}\n    for lyricAttributes, lyricBody in noteXml:gmatch(\"<lyric([^>]*)>(.-)</lyric>\") do\n        local lyricNumber = lyricAttributes:match('number%s*=%s*\"([^\"]+)\"')\n            or lyricAttributes:match(\"number%s*=%s*'([^']+)'\")\n        lyricCandidates[#lyricCandidates + 1] = {\n            number = lyricNumber,\n            body = lyricBody\n        }\n    end\n\n    if #lyricCandidates == 0 then\n        return nil, false, false, false\n    end\n\n    local lyricBlock\n    for _, candidate in ipairs(lyricCandidates) do\n        if candidate.number == \"1\" then\n            lyricBlock = candidate.body\n            break\n        end\n    end\n    if not lyricBlock then\n        for _, candidate in ipairs(lyricCandidates) do\n            if not candidate.number then\n                lyricBlock = candidate.body\n                break\n            end\n        end\n    end\n    lyricBlock = lyricBlock or lyricCandidates[1].body\n\n    local lyricTextParts = {}\n    local pendingElision = false\n    local cursor = 1\n\n    while true do\n        local nextTextStart, nextTextEnd = lyricBlock:find(\"<text[^>]*>\", cursor)\n        local nextElisionStart = lyricBlock:find(\"<elision\", cursor)\n\n        if not nextTextStart and not nextElisionStart then\n            break\n        end\n\n        if nextElisionStart and (not nextTextStart or nextElisionStart < nextTextStart) then\n            local elisionClose = lyricBlock:find(\"</elision>\", nextElisionStart, true)\n            local selfClosingClose = lyricBlock:find(\"/>\", nextElisionStart, true)\n            if elisionClose then\n                cursor = elisionClose + #\"</elision>\"\n            elseif selfClosingClose then\n                cursor = selfClosingClose + 2\n            else\n                cursor = nextElisionStart + 1\n            end\n            pendingElision = #lyricTextParts > 0\n        else\n            local textCloseStart, textCloseEnd = lyricBlock:find(\"</text>\", nextTextStart, true)\n            if not textCloseStart then\n                break\n            end\n            local textValueStart = nextTextEnd + 1\n            local textValue = lyricBlock:sub(textValueStart, textCloseStart - 1)\n            if pendingElision and #lyricTextParts > 0 then\n                lyricTextParts[#lyricTextParts + 1] = \"‿\"\n            end\n            lyricTextParts[#lyricTextParts + 1] = xmlUnescape(textValue)\n            pendingElision = false\n            cursor = textCloseEnd + 1\n        end\n    end\n\n    local text = table.concat(lyricTextParts)\n    local syllabic = lyricBlock:match(\"<syllabic[^>]*>(.-)</syllabic>\")\n    if syllabic then\n        local normalizedSyllabic = xmlUnescape(syllabic):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\"):lower()\n        if text ~= \"\" and (normalizedSyllabic == \"begin\" or normalizedSyllabic == \"middle\") and not text:find(\"-$\") then\n            text = text .. \"-\"\n        end\n    end\n\n    local hasExtend = lyricBlock:find(\"<extend[^>]*/>\") ~= nil or lyricBlock:find(\"<extend[^>]*>.-</extend>\") ~= nil\n    local hasLineBreak = lyricBlock:find(\"<end%-line[^>]*/>\") ~= nil or lyricBlock:find(\"<end%-line[^>]*>.-</end%-line>\") ~= nil\n    local hasParagraphBreak = lyricBlock:find(\"<end%-paragraph[^>]*/>\") ~= nil or lyricBlock:find(\"<end%-paragraph[^>]*>.-</end%-paragraph>\") ~= nil\n    if text == \"\" then\n        return nil, hasExtend, hasLineBreak, hasParagraphBreak\n    end\n\n    return text, hasExtend, hasLineBreak, hasParagraphBreak\nend\n\nlocal function buildLyricXml(lyric, shouldExtend, previousLyric)\n    local lyricValue = type(lyric) == \"string\" and lyric or \"\"\n    local normalizedLyric = lyricValue:gsub(\"\\r\", \"\")\n    local hasParagraphBreak = normalizedLyric:find(\"\\n\\n\", 1, true) ~= nil\n    local hasLineBreak = (not hasParagraphBreak) and normalizedLyric:find(\"\\n\", 1, true) ~= nil\n    if hasParagraphBreak or hasLineBreak then\n        lyricValue = normalizedLyric:gsub(\"\\n+\", \"\")\n    end\n\n    local melismaContinuation = lyric == \"_\"\n    local hasTrailingHyphen = lyricValue:find(\"-$\") ~= nil\n    local coreLyricValue = lyricValue:gsub(\"-+$\", \"\")\n    local hasLyricText = coreLyricValue ~= \"\" and coreLyricValue ~= \"_\"\n    if not hasLyricText and not melismaContinuation then\n        return nil\n    end\n\n    local lyricParts = {\"<lyric>\"}\n    if hasLyricText then\n        local previousLyricValue = type(previousLyric) == \"string\" and previousLyric:gsub(\"\\r\", \"\"):gsub(\"\\n+\", \"\") or \"\"\n        local previousContinues = previousLyricValue:find(\"-$\") ~= nil\n        local syllabic\n        if hasTrailingHyphen then\n            syllabic = previousContinues and \"middle\" or \"begin\"\n        else\n            syllabic = previousContinues and \"end\" or \"single\"\n        end\n        lyricParts[#lyricParts + 1] = \"<syllabic>\" .. syllabic .. \"</syllabic>\"\n\n        local hadElision = false\n        for segment in coreLyricValue:gmatch(\"[^‿]+\") do\n            if hadElision then\n                lyricParts[#lyricParts + 1] = \"<elision>‿</elision>\"\n            end\n            lyricParts[#lyricParts + 1] = \"<text>\" .. xmlEscape(segment) .. \"</text>\"\n            hadElision = true\n        end\n    end\n    if melismaContinuation or shouldExtend then\n        lyricParts[#lyricParts + 1] = \"<extend/>\"\n    end\n    if hasParagraphBreak then\n        lyricParts[#lyricParts + 1] = \"<end-paragraph/>\"\n    elseif hasLineBreak then\n        lyricParts[#lyricParts + 1] = \"<end-line/>\"\n    end\n    lyricParts[#lyricParts + 1] = \"</lyric>\"\n    return table.concat(lyricParts)\nend\n\nlocal DIVISIONS = 24  -- ticks per quarter note; evenly represents all stepBeats options\n\nlocal function stepBeatsToDuration(stepBeats)\n    local ticks = math.floor(stepBeats * DIVISIONS + 0.5)\n    if     math.abs(stepBeats - 4)     < 0.001 then return ticks, \"whole\",   false, false\n    elseif math.abs(stepBeats - 3)     < 0.001 then return ticks, \"half\",    true,  false\n    elseif math.abs(stepBeats - 2)     < 0.001 then return ticks, \"half\",    false, false\n    elseif math.abs(stepBeats - 1.5)   < 0.001 then return ticks, \"quarter\", true,  false\n    elseif math.abs(stepBeats - 1)     < 0.001 then return ticks, \"quarter\", false, false\n    elseif math.abs(stepBeats - 0.75)  < 0.001 then return ticks, \"eighth\",  true,  false\n    elseif math.abs(stepBeats - 0.5)   < 0.001 then return ticks, \"eighth\",  false, false\n    elseif math.abs(stepBeats - 1/3)   < 0.01  then return ticks, \"eighth\",  false, true\n    elseif math.abs(stepBeats - 0.25)  < 0.001 then return ticks, \"16th\",    false, false\n    elseif math.abs(stepBeats - 0.125) < 0.001 then return ticks, \"32nd\",    false, false\n    else                                             return ticks, \"quarter\", false, false\n    end\nend\n\n-- Circle-of-fifths index (sharps > 0, flats < 0) for pitch classes 0–11 (C … B)\nlocal MAJOR_KEY_FIFTHS = {0, -5, 2, -3, 4, -1, 6, 1, -4, 3, -2, 5}\nlocal MINOR_KEY_FIFTHS = {-3, 4, -1, -6, 1, -4, 3, -2, 5, 0, -5, 2}\n\nlocal function getKeySignature(keyNote, solfegeScale)\n    local idx = (keyNote or 0) + 1\n    if solfegeScale == \"major\" or solfegeScale == \"all\" or solfegeScale == \"custom\" or not solfegeScale then\n        return MAJOR_KEY_FIFTHS[idx] or 0, \"major\"\n    else\n        return MINOR_KEY_FIFTHS[idx] or 0, \"minor\"\n    end\nend\n\nlocal function addPartXml(lines, state, sequenceIndex)\n    local sequence = state.sequences[sequenceIndex] or {}\n    local sequenceLength = state.sequenceLengths[sequenceIndex] or 0\n    local meterNumerator = math.max(1, math.floor(state.meterNumerator or 4))\n    local meterDenominator = math.max(1, math.floor(state.meterDenominator or 4))\n    local stepBeats = state.stepBeats or 1\n    local quarterBeatsPerMeasure = meterNumerator * (4.0 / meterDenominator)\n    local stepsPerMeasure = math.max(1, math.floor(quarterBeatsPerMeasure / stepBeats + 0.5))\n\n    local noteDuration, noteType, isDotted, isTriplet = stepBeatsToDuration(stepBeats)\n    local keyFifths, keyMode = getKeySignature(state.keyNote, state.solfegeScale)\n\n    -- Pre-build the duration fragment shared by every note\n    local durationXml\n    do\n        local parts = {\n            string.format('<duration>%d</duration>', noteDuration),\n            string.format('<type>%s</type>', noteType),\n        }\n        if isDotted then parts[#parts + 1] = '<dot/>' end\n        if isTriplet then\n            parts[#parts + 1] = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'\n        end\n        durationXml = table.concat(parts)\n    end\n\n    table.insert(lines, string.format('  <part id=\"P%d\">', sequenceIndex))\n\n    local totalSlots = math.max(sequenceLength, 1)\n    local measureCount = math.max(1, math.ceil(totalSlots / stepsPerMeasure))\n    local tempo = math.floor(state.tempo or 80)\n\n    for measureIndex = 1, measureCount do\n        table.insert(lines, string.format('    <measure number=\"%d\">', measureIndex))\n        if measureIndex == 1 then\n            table.insert(lines, '      <attributes>')\n            table.insert(lines, string.format('        <divisions>%d</divisions>', DIVISIONS))\n            table.insert(lines, string.format('        <key><fifths>%d</fifths><mode>%s</mode></key>', keyFifths, keyMode))\n            table.insert(lines, '        <time>')\n            table.insert(lines, '          <beats>' .. meterNumerator .. '</beats>')\n            table.insert(lines, '          <beat-type>' .. meterDenominator .. '</beat-type>')\n            table.insert(lines, '        </time>')\n            table.insert(lines, '        <clef><sign>G</sign><line>2</line></clef>')\n            table.insert(lines, '      </attributes>')\n            if sequenceIndex == 1 then\n                table.insert(lines, string.format('      <direction placement=\"above\"><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>%d</per-minute></metronome></direction-type><sound tempo=\"%d\"/></direction>', tempo, tempo))\n            end\n            if state.loopPlayback then\n                table.insert(lines, '      <barline location=\"left\"><bar-style>heavy-light</bar-style><repeat direction=\"forward\"/></barline>')\n            end\n        end\n\n        local measureStartSlot = ((measureIndex - 1) * stepsPerMeasure) + 1\n        local measureEndSlot = measureIndex * stepsPerMeasure\n\n        for sequenceSlotIndex = measureStartSlot, measureEndSlot do\n            local slotData = sequence[sequenceSlotIndex]\n            local nextSlotData = sequence[sequenceSlotIndex + 1]\n            local shouldExtend = nextSlotData and nextSlotData.lyric == \"_\"\n            local previousSlotData = sequenceSlotIndex > 1 and sequence[sequenceSlotIndex - 1] or nil\n            local previousLyric = previousSlotData and previousSlotData.lyric or nil\n\n            if not slotData then\n                table.insert(lines, string.format('      <note><rest/>%s</note>', durationXml))\n            elseif slotData.notes then\n                local lyric = slotData.lyric\n                for noteIdx, chordNote in ipairs(slotData.notes) do\n                    local pitch = toPitch(chordNote.note, chordNote.octave)\n                    if not pitch then\n                        table.insert(lines, string.format('      <note><rest/>%s</note>', durationXml))\n                    else\n                        table.insert(lines, '      <note>')\n                        if noteIdx > 1 then\n                            table.insert(lines, '        <chord/>')\n                        end\n                        table.insert(lines, '        <pitch>')\n                        table.insert(lines, '          <step>' .. pitch.step .. '</step>')\n                        if pitch.alter ~= 0 then\n                            table.insert(lines, '          <alter>' .. pitch.alter .. '</alter>')\n                        end\n                        table.insert(lines, '          <octave>' .. pitch.octave .. '</octave>')\n                        table.insert(lines, '        </pitch>')\n                        table.insert(lines, '        ' .. durationXml)\n                        if noteIdx == 1 then\n                            local lyricXml = buildLyricXml(lyric, shouldExtend, previousLyric)\n                            if lyricXml then\n                                table.insert(lines, '        ' .. lyricXml)\n                            end\n                        end\n                        table.insert(lines, '      </note>')\n                    end\n                end\n            else\n                local pitch = toPitch(slotData.note, slotData.octave)\n                if not pitch then\n                    -- Rest step: include lyric if one is attached (Lyrics mode creates rest+lyric steps)\n                    local lyricXml = buildLyricXml(slotData.lyric, shouldExtend, previousLyric)\n                    if lyricXml then\n                        table.insert(lines, string.format('      <note><rest/>%s', durationXml))\n                        table.insert(lines, '        ' .. lyricXml)\n                        table.insert(lines, '      </note>')\n                    else\n                        table.insert(lines, string.format('      <note><rest/>%s</note>', durationXml))\n                    end\n                else\n                    table.insert(lines, '      <note>')\n                    table.insert(lines, '        <pitch>')\n                    table.insert(lines, '          <step>' .. pitch.step .. '</step>')\n                    if pitch.alter ~= 0 then\n                        table.insert(lines, '          <alter>' .. pitch.alter .. '</alter>')\n                    end\n                    table.insert(lines, '          <octave>' .. pitch.octave .. '</octave>')\n                    table.insert(lines, '        </pitch>')\n                    table.insert(lines, '        ' .. durationXml)\n                    local lyricXml = buildLyricXml(slotData.lyric, shouldExtend, previousLyric)\n                    if lyricXml then\n                        table.insert(lines, '        ' .. lyricXml)\n                    end\n                    table.insert(lines, '      </note>')\n                end\n            end\n        end\n\n        if state.loopPlayback and measureIndex == measureCount then\n            table.insert(lines, '      <barline location=\"right\"><bar-style>light-heavy</bar-style><repeat direction=\"backward\"/></barline>')\n        end\n        table.insert(lines, '    </measure>')\n    end\n    table.insert(lines, '  </part>')\nend\n\nlocal function buildWorkTitle()\n    return \"Solfege Sequence Export\"\nend\n\nlocal function buildPartName(sequenceIndex)\n    return string.format(\"Sequence %d\", sequenceIndex)\nend\n\nfunction MusicXML.exportStateToString(state, core)\n    local lines = {\n        '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n        '<score-partwise version=\"3.1\">',\n        '  <work><work-title>' .. xmlEscape(buildWorkTitle()) .. '</work-title></work>',\n        '  <identification>',\n        '    <creator type=\"software\">solfege</creator>',\n        '    <encoding><software>solfege</software></encoding>',\n        '    <miscellaneous>'\n    }\n\n    local fields = buildMetadataFields(state)\n    for key, value in pairs(fields) do\n        table.insert(lines, string.format('      <miscellaneous-field name=\"%s\">%s</miscellaneous-field>', xmlEscape(key), xmlEscape(tostring(value))))\n    end\n\n    table.insert(lines, '    </miscellaneous>')\n    table.insert(lines, '  </identification>')\n    table.insert(lines, '  <part-list>')\n\n    for sequenceIndex = 1, core.maxSequences do\n        table.insert(lines, string.format('    <score-part id=\"P%d\"><part-name>%s</part-name></score-part>', sequenceIndex, xmlEscape(buildPartName(sequenceIndex))))\n    end\n\n    table.insert(lines, '  </part-list>')\n\n    for sequenceIndex = 1, core.maxSequences do\n        addPartXml(lines, state, sequenceIndex)\n    end\n\n    table.insert(lines, '</score-partwise>')\n    return table.concat(lines, \"\\n\")\nend\n\nfunction MusicXML.exportMusicXMLFile(state, core, filename, writeText)\n    local xml = MusicXML.exportStateToString(state, core)\n    if type(writeText) == \"function\" then\n        return writeText(filename, xml)\n    end\n\n    local file = io.open(filename, \"w\")\n    if not file then\n        return false, \"Could not open file for writing: \" .. tostring(filename)\n    end\n    file:write(xml)\n    file:close()\n    return true\nend\n\nlocal function applyMetadataToState(state, metadata)\n    local numberFields = {\n        \"tempo\", \"stepBeats\", \"meterNumerator\", \"meterDenominator\", \"activeSequenceIndex\", \"selectedNote\", \"currentOctave\",\n        \"defaultTempo\", \"keyLeadInBeats\", \"rootNote\", \"keyNote\", \"keyOctave\",\n        \"playbackStopSeconds\", \"droneNoteSelection\", \"droneOctave\", \"selectedPatternType\",\n        \"selectedPattern\", \"patternTargetSequenceIndex\", \"patternOctave\", \"masterVolume\", \"rowBreakAfterStep\"\n    }\n\n    for _, field in ipairs(numberFields) do\n        if metadata[field] ~= nil then\n            local parsed = parseNumber(metadata[field])\n            if parsed ~= nil then\n                state[field] = parsed\n            end\n        end\n    end\n\n    local booleanFields = {\n        \"showHandsDuringPlayback\", \"soundPreviewEnabled\", \"acapellaMode\", \"darkMode\",\n        \"pitchRecognitionEnabled\", \"playKeyBeforeSteps\", \"randomizeRootPlayback\",\n        \"randomizeOctavePlayback\", \"hideNoteNamesDuringPlayback\", \"useShapeNotes\",\n        \"sidebarOpen\", \"singSolfegeMode\", \"showLyrics\", \"showSolfegeLyrics\", \"hideSteps\",\n        \"droneEnabled\"\n    }\n\n    for _, field in ipairs(booleanFields) do\n        if metadata[field] ~= nil then\n            state[field] = metadata[field] == \"true\" or metadata[field] == \"1\"\n        end\n    end\n\n    if metadata.selectedPatternByType then\n        local values = {}\n        for value in metadata.selectedPatternByType:gmatch(\"[^,]+\") do\n            table.insert(values, tonumber(value) or 1)\n        end\n        if #values > 0 then\n            state.selectedPatternByType = values\n        end\n    end\n\n    if metadata.sequenceMutes then\n        local values = {}\n        for value in metadata.sequenceMutes:gmatch(\"[^,]+\") do\n            table.insert(values, value == \"1\" or value == \"true\")\n        end\n        if #values > 0 then\n            state.sequenceMutes = values\n        end\n    end\n\n    if metadata.sequenceOctaveTranspose then\n        local values = {}\n        for value in metadata.sequenceOctaveTranspose:gmatch(\"[^,]+\") do\n            table.insert(values, tonumber(value) or 0)\n        end\n        if #values > 0 then\n            state.sequenceOctaveTranspose = values\n        end\n    end\n\n    if metadata.solfegeScale ~= nil then\n        state.solfegeScale = tostring(metadata.solfegeScale)\n    end\n\n    if metadata.lyricNotesBuffer ~= nil then\n        state.lyricNotesBuffer = tostring(metadata.lyricNotesBuffer)\n    end\n\n    if metadata.solfegeInputBuffer ~= nil then\n        state.solfegeInputBuffer = tostring(metadata.solfegeInputBuffer)\n    end\n\n    if metadata.solfegeTextMode ~= nil then\n        local m = tostring(metadata.solfegeTextMode)\n        if m == \"steps\" or m == \"lyrics\" or m == \"both\" then\n            state.solfegeTextMode = m\n        end\n    end\n\n    state.linkedLyricsDocxPath = nil\n    if metadata.linkedLyricsDocxPath ~= nil then\n        state.linkedLyricsDocxPath = tostring(metadata.linkedLyricsDocxPath)\n    end\n\n    if type(state.masterVolume) == \"number\" then\n        state.masterVolume = math.max(0, math.min(1, state.masterVolume))\n    end\nend\n\nlocal function captureStateSnapshot(state)\n    return {\n        metadata = buildMetadataFields(state),\n        sequences = deepCopy(state.sequences),\n        sequenceLengths = deepCopy(state.sequenceLengths)\n    }\nend\n\nlocal function applyStateSnapshot(state, core, snapshot)\n    if type(snapshot) ~= \"table\" then\n        return false, \"Snapshot must be a table\"\n    end\n\n    local metadata = snapshot.metadata or {}\n    state.sequences = deepCopy(snapshot.sequences or {})\n    state.sequenceLengths = deepCopy(snapshot.sequenceLengths or {})\n\n    for i = 1, core.maxSequences do\n        if type(state.sequences[i]) ~= \"table\" then\n            state.sequences[i] = {}\n        end\n        if type(state.sequenceLengths[i]) ~= \"number\" then\n            state.sequenceLengths[i] = 0\n        end\n    end\n\n    applyMetadataToState(state, metadata)\n\n    core.setTimeSignature(state, state.meterNumerator, state.meterDenominator)\n    state.activeSequenceIndex = math.min(math.max(1, state.activeSequenceIndex or 1), core.maxSequences)\n    state.sequence = state.sequences[state.activeSequenceIndex] or {}\n    state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\n\n    local stepBeats = state.stepBeats or 1\n    state.stepDuration = (60 / state.tempo) * 1000 * stepBeats\n\n    return true\nend\n\nlocal function pushHistoryEntry(state, stackName, snapshot)\n    if type(snapshot) ~= \"table\" then\n        return\n    end\n    if type(state[stackName]) ~= \"table\" then\n        state[stackName] = {}\n    end\n    table.insert(state[stackName], snapshot)\nend\n\nlocal function popHistoryEntry(state, stackName)\n    if type(state[stackName]) ~= \"table\" then\n        return nil\n    end\n    return table.remove(state[stackName])\nend\n\nlocal function resolvePartSequenceIndex(partId, orderedIndex, usedSequences, maxSequences)\n    local numericSuffix = tonumber(tostring(partId or \"\"):match(\"^P(%d+)$\"))\n    if numericSuffix and numericSuffix >= 1 and numericSuffix <= maxSequences and not usedSequences[numericSuffix] then\n        usedSequences[numericSuffix] = true\n        return numericSuffix\n    end\n\n    local fallback = orderedIndex\n    if fallback >= 1 and fallback <= maxSequences and not usedSequences[fallback] then\n        usedSequences[fallback] = true\n        return fallback\n    end\n\n    for index = 1, maxSequences do\n        if not usedSequences[index] then\n            usedSequences[index] = true\n            return index\n        end\n    end\n\n    return nil\nend\n\nlocal function importIntoState(state, core, xml)\n    local metadata = {}\n    for name, value in xml:gmatch('<miscellaneous%-field%s+name%s*=%s*\"([^\"]+)\"[^>]*>(.-)</miscellaneous%-field>') do\n        metadata[xmlUnescape(name)] = xmlUnescape(value)\n    end\n    for name, value in xml:gmatch(\"<miscellaneous%-field%s+name%s*=%s*'([^']+)'[^>]*>(.-)</miscellaneous%-field>\") do\n        metadata[xmlUnescape(name)] = xmlUnescape(value)\n    end\n\n    local beats = xml:match(\"<time>%s*<beats>(.-)</beats>\")\n    local beatType = xml:match(\"<time>.-<beat%-type>(.-)</beat%-type>\")\n    if beats and beatType then\n        metadata.meterNumerator = beats\n        metadata.meterDenominator = beatType\n    end\n\n    for i = 1, core.maxSequences do\n        state.sequences[i] = {}\n        state.sequenceLengths[i] = 0\n    end\n\n    local parsedParts = {}\n    local partById = {}\n    for partId, partXml in xml:gmatch('<part%s+id%s*=%s*\"([^\"]+)\"[^>]*>(.-)</part>') do\n        partById[partId] = partXml\n    end\n    for partId, partXml in xml:gmatch(\"<part%s+id%s*=%s*'([^']+)'[^>]*>(.-)</part>\") do\n        partById[partId] = partXml\n    end\n\n    for listedPartId in xml:gmatch('<score%-part%s+id%s*=%s*\"([^\"]+)\"') do\n        local partXml = partById[listedPartId]\n        if partXml then\n            table.insert(parsedParts, {id = listedPartId, xml = partXml})\n            partById[listedPartId] = nil\n        end\n    end\n    for listedPartId in xml:gmatch(\"<score%-part%s+id%s*=%s*'([^']+)'\") do\n        local partXml = partById[listedPartId]\n        if partXml then\n            table.insert(parsedParts, {id = listedPartId, xml = partXml})\n            partById[listedPartId] = nil\n        end\n    end\n\n    for partId, partXml in pairs(partById) do\n        table.insert(parsedParts, {id = partId, xml = partXml})\n    end\n\n    local usedSequences = {}\n    for orderedIndex, partData in ipairs(parsedParts) do\n        local sequenceIndex = resolvePartSequenceIndex(partData.id, orderedIndex, usedSequences, core.maxSequences)\n        if sequenceIndex then\n            local sequenceSlotIndex = 1\n            local melismaActive = false\n            for noteXml in partData.xml:gmatch(\"<note[^>]*>(.-)</note>\") do\n                local isChord = noteXml:find(\"<chord%s*/>\") ~= nil\n                local note, octave = parsePitch(noteXml)\n                local lyric, hasExtend, hasLineBreak, hasParagraphBreak = parseLyric(noteXml)\n                if not lyric and hasExtend and melismaActive then\n                    lyric = \"_\"\n                end\n                if lyric and hasParagraphBreak then\n                    lyric = lyric .. \"\\n\\n\"\n                elseif lyric and hasLineBreak then\n                    lyric = lyric .. \"\\n\"\n                end\n                melismaActive = hasExtend\n                if isChord then\n                    local previousSlotIndex = math.max(1, sequenceSlotIndex - 1)\n                    local target = state.sequences[sequenceIndex][previousSlotIndex]\n                    if lyric and lyric ~= \"\" and target then\n                        target.lyric = lyric\n                    end\n                    if note and note ~= 13 then\n                        if target and target.notes then\n                            table.insert(target.notes, {note = note, octave = octave})\n                        elseif target and target.note ~= nil then\n                            state.sequences[sequenceIndex][previousSlotIndex] = {\n                                notes = {\n                                    {note = target.note, octave = target.octave},\n                                    {note = note, octave = octave}\n                                },\n                                lyric = target.lyric\n                            }\n                        else\n                            state.sequences[sequenceIndex][previousSlotIndex] = {note = note, octave = octave, lyric = lyric}\n                        end\n                    end\n                else\n                    if note == 13 then\n                        -- Preserve rest steps that carry a lyric (created by Lyrics mode)\n                        if lyric and lyric ~= \"\" then\n                            state.sequences[sequenceIndex][sequenceSlotIndex] = {note = 13, octave = octave, lyric = lyric}\n                        else\n                            state.sequences[sequenceIndex][sequenceSlotIndex] = nil\n                        end\n                    elseif note ~= nil then\n                        state.sequences[sequenceIndex][sequenceSlotIndex] = {note = note, octave = octave, lyric = lyric}\n                    end\n                    sequenceSlotIndex = sequenceSlotIndex + 1\n                end\n            end\n            -- Trim to last slot with actual note data; empty trailing measures expand\n            -- sequenceSlotIndex far beyond the real content (e.g. a file once saved\n            -- with a note at step 9999 would import with sequenceLength = 9999 even\n            -- after that note is removed, because the empty measures remain in the XML).\n            local rawLen = math.max(0, sequenceSlotIndex - 1)\n            local trimmedLen = 0\n            for i = rawLen, 1, -1 do\n                local s = state.sequences[sequenceIndex][i]\n                if s and (s.note ~= nil or (s.notes and #s.notes > 0) or (s.lyric and s.lyric ~= \"\")) then\n                    trimmedLen = i\n                    break\n                end\n            end\n            state.sequenceLengths[sequenceIndex] = trimmedLen\n        end\n    end\n\n    applyMetadataToState(state, metadata)\n\n    if metadata.tempo == nil then\n        local tempoString = xml:match('<sound%s+tempo=\"([%d%.]+)\"')\n        local tempo = parseNumber(tempoString)\n        if tempo then\n            state.tempo = tempo\n        end\n    end\n\n    state.activeSequenceIndex = math.min(math.max(1, state.activeSequenceIndex or 1), core.maxSequences)\n    if not state.sequences[state.activeSequenceIndex] then\n        state.activeSequenceIndex = 1\n    end\n    state.sequence = state.sequences[state.activeSequenceIndex]\n    state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\nend\n\nfunction MusicXML.importStateFromString(state, core, xml)\n    if type(xml) ~= \"string\" then\n        return false, \"MusicXML contents must be a string\"\n    end\n\n    if not xml:find(\"<score%-partwise\") then\n        return false, \"Not a MusicXML score-partwise document\"\n    end\n\n    local previousSnapshot = captureStateSnapshot(state)\n    importIntoState(state, core, xml)\n    pushHistoryEntry(state, \"musicxmlUndoStack\", previousSnapshot)\n    state.musicxmlRedoStack = {}\n\n    return true\nend\n\nfunction MusicXML.undoImport(state, core)\n    local snapshot = popHistoryEntry(state, \"musicxmlUndoStack\")\n    if not snapshot then\n        return false, \"No MusicXML changes to undo\"\n    end\n\n    pushHistoryEntry(state, \"musicxmlRedoStack\", captureStateSnapshot(state))\n    return applyStateSnapshot(state, core, snapshot)\nend\n\nfunction MusicXML.redoImport(state, core)\n    local snapshot = popHistoryEntry(state, \"musicxmlRedoStack\")\n    if not snapshot then\n        return false, \"No MusicXML changes to redo\"\n    end\n\n    pushHistoryEntry(state, \"musicxmlUndoStack\", captureStateSnapshot(state))\n    return applyStateSnapshot(state, core, snapshot)\nend\n\nfunction MusicXML.importMusicXMLFile(state, core, filename, readText)\n    local xml\n    if type(readText) == \"function\" then\n        xml = readText(filename)\n    else\n        local file = io.open(filename, \"r\")\n        if file then\n            xml = file:read(\"*a\")\n            file:close()\n        end\n    end\n\n    if not xml or xml == \"\" then\n        return false, \"Could not read MusicXML file: \" .. tostring(filename)\n    end\n\n    return MusicXML.importStateFromString(state, core, xml)\nend\n\nreturn MusicXML\n","onedrive_paths.lua":"local OneDrivePaths = {}\n\nlocal function shellQuote(value)\n    return \"'\" .. tostring(value):gsub(\"'\", [['\"'\"']]) .. \"'\"\nend\n\nlocal function trimTrailingSeparators(path)\n    if type(path) ~= \"string\" then\n        return nil\n    end\n\n    local trimmed = path:gsub(\"[/\\\\]+$\", \"\")\n    if trimmed == \"\" then\n        return path\n    end\n    return trimmed\nend\n\nlocal function dirname(path)\n    if type(path) ~= \"string\" or path == \"\" then\n        return nil\n    end\n\n    local normalized = path:gsub(\"\\\\\", \"/\")\n    local dir = normalized:match(\"^(.*)/[^/]*$\")\n    if dir and dir ~= \"\" then\n        return dir\n    end\n\n    return nil\nend\n\nlocal function readFirstLine(command, popen)\n    local openPipe = popen or io.popen\n    local pipe = openPipe(command, \"r\")\n    if not pipe then\n        return nil\n    end\n\n    local line = pipe:read(\"*l\")\n    pipe:close()\n    if not line or line == \"\" then\n        return nil\n    end\n    return trimTrailingSeparators(line)\nend\n\nlocal function directoryExists(path, popen)\n    if type(path) ~= \"string\" or path == \"\" then\n        return false\n    end\n\n    if not popen then\n        local f = io.open(path .. \"/.\", \"r\")\n        if f then\n            f:close()\n            return true\n        end\n        return false\n    end\n\n    local cmd = \"[ -d \" .. shellQuote(path) .. \" ] && printf '1\\\\n'\"\n    return readFirstLine(cmd, popen) == \"1\"\nend\n\nlocal function ensureDirectory(path, execute)\n    if type(path) ~= \"string\" or path == \"\" then\n        return false\n    end\n\n    local runner = execute or os.execute\n    local ok = runner(\"mkdir -p \" .. shellQuote(path))\n    return ok == true or ok == 0\nend\n\nlocal function defaultReadFile(path)\n    local file = io.open(path, \"r\")\n    if not file then\n        return nil\n    end\n    local raw = file:read(\"*a\")\n    file:close()\n    return raw\nend\n\nlocal function defaultWriteFile(path, contents)\n    local file = io.open(path, \"w\")\n    if not file then\n        return false, \"Could not open file for writing: \" .. tostring(path)\n    end\n    file:write(contents)\n    file:close()\n    return true\nend\n\nfunction OneDrivePaths.getOverrideFilePath(options)\n    options = options or {}\n\n    local getenv = options.getenv or os.getenv\n    local home = options.home or getenv(\"HOME\")\n    local jitOs = options.jitOs or (jit and jit.os) or nil\n\n    if not home or home == \"\" then\n        return nil\n    end\n\n    if jitOs == \"OSX\" then\n        return home .. \"/Library/Application Support/solfege/onedrive_root.txt\"\n    end\n\n    return home .. \"/.config/solfege/onedrive_root.txt\"\nend\n\nfunction OneDrivePaths.readSavedRoot(options)\n    options = options or {}\n\n    local readFile = options.readFile or defaultReadFile\n    local savedPath = OneDrivePaths.getOverrideFilePath(options)\n    if not savedPath then\n        return nil\n    end\n\n    local raw = readFile(savedPath)\n    if type(raw) ~= \"string\" then\n        return nil\n    end\n\n    local savedRoot = trimTrailingSeparators(raw:match(\"^%s*(.-)%s*$\"))\n    if savedRoot and savedRoot ~= \"\" and directoryExists(savedRoot, options.popen) then\n        return savedRoot\n    end\n\n    return nil\nend\n\nfunction OneDrivePaths.saveRoot(root, options)\n    options = options or {}\n\n    local savedPath = OneDrivePaths.getOverrideFilePath(options)\n    local normalizedRoot = trimTrailingSeparators(root)\n    if not savedPath then\n        return false, \"HOME is unavailable\"\n    end\n    if not normalizedRoot or normalizedRoot == \"\" then\n        return false, \"Missing OneDrive path\"\n    end\n\n    local parentDir = dirname(savedPath)\n    local ensureDir = options.ensureDirectory or ensureDirectory\n    if parentDir and parentDir ~= \"\" and not ensureDir(parentDir, options.execute) then\n        return false, \"Could not create OneDrive config directory\"\n    end\n\n    local writeFile = options.writeFile or defaultWriteFile\n    return writeFile(savedPath, normalizedRoot .. \"\\n\")\nend\n\nfunction OneDrivePaths.resolveRoot(options)\n    options = options or {}\n\n    local getenv = options.getenv or os.getenv\n    local popen = options.popen\n    local home = options.home or getenv(\"HOME\")\n    local jitOs = options.jitOs or (jit and jit.os) or nil\n\n    local explicit = trimTrailingSeparators(getenv(\"SOLFEGE_ONEDRIVE_DIR\"))\n    if explicit and explicit ~= \"\" then\n        return explicit\n    end\n\n    local savedRoot = OneDrivePaths.readSavedRoot(options)\n    if savedRoot then\n        return savedRoot\n    end\n\n    local envCandidates = {\n        getenv(\"OneDrive\"),\n        getenv(\"OneDriveConsumer\"),\n        getenv(\"OneDriveCommercial\"),\n    }\n\n    for _, candidate in ipairs(envCandidates) do\n        candidate = trimTrailingSeparators(candidate)\n        if candidate and candidate ~= \"\" and directoryExists(candidate, popen) then\n            return candidate\n        end\n    end\n\n    if not home or home == \"\" then\n        return nil\n    end\n\n    local directCandidates = {\n        home .. \"/OneDrive\",\n        home .. \"/OneDrive - Personal\",\n    }\n\n    for _, candidate in ipairs(directCandidates) do\n        if directoryExists(candidate, popen) then\n            return trimTrailingSeparators(candidate)\n        end\n    end\n\n    if jitOs == \"OSX\" then\n        local cloudStorageCommand = \"ls -d \" .. shellQuote(home) .. \"/Library/CloudStorage/OneDrive* 2>/dev/null\"\n        local cloudRoot = readFirstLine(cloudStorageCommand, popen)\n        if cloudRoot and cloudRoot ~= \"\" then\n            return cloudRoot\n        end\n    end\n\n    return nil\nend\n\nfunction OneDrivePaths.resolveAppDirectories(options)\n    local root = OneDrivePaths.resolveRoot(options)\n    if not root then\n        return nil\n    end\n\n    return {\n        root = root,\n        appRoot = root .. \"/solfege\",\n        storageRoot = root .. \"/solfege/appdata\",\n        musicXMLRoot = root .. \"/solfege/musicxml\",\n    }\nend\n\nreturn OneDrivePaths\n","platform_config.lua":"local PlatformConfig = {}\n\nlocal function detectPlatform()\n    local override = rawget(_G, \"SOLFEGE_PLATFORM\")\n    if override then\n        return override\n    end\n    if rawget(_G, \"playdate\") ~= nil then\n        return \"playdate\"\n    end\n    if rawget(_G, \"SDLHost\") ~= nil or rawget(_G, \"sdlHost\") ~= nil or rawget(_G, \"sdl\") ~= nil then\n        return \"sdl\"\n    end\n    if rawget(_G, \"window\") ~= nil or rawget(_G, \"document\") ~= nil then\n        return \"web\"\n    end\n    return \"web\"\nend\n\nfunction PlatformConfig.getPlatformName()\n    return detectPlatform()\nend\n\nfunction PlatformConfig.getAdapters()\n    local platformName = detectPlatform()\n\n    if platformName == \"web\" then\n        local WebInput = import \"web_input\"\n        local WebGraphics = import \"web_graphics\"\n        local WebAudio = import \"web_audio\"\n        local WebStorage = import \"web_storage\"\n        local WebTimer = import \"web_timer\"\n        local WebSystem = import \"web_system\"\n\n        return {\n            name = platformName,\n            Input = WebInput,\n            Graphics = WebGraphics,\n            Audio = WebAudio,\n            Storage = WebStorage,\n            Timer = WebTimer,\n            System = WebSystem\n        }\n    end\n    if platformName == \"sdl\" then\n        local SDLInput = import \"sdl_input\"\n        local SDLGraphics = import \"sdl_graphics\"\n        local SDLAudio = import \"sdl_audio\"\n        local SDLStorage = import \"sdl_storage\"\n        local SDLTimer = import \"sdl_timer\"\n        local SDLSystem = import \"sdl_system\"\n\n        return {\n            name = platformName,\n            Input = SDLInput,\n            Graphics = SDLGraphics,\n            Audio = SDLAudio,\n            Storage = SDLStorage,\n            Timer = SDLTimer,\n            System = SDLSystem\n        }\n    end\n    local PlaydateInput = import \"playdate_input\"\n    local PlaydateGraphics = import \"playdate_graphics\"\n    local PlaydateAudio = import \"playdate_audio\"\n    local PlaydateStorage = import \"playdate_storage\"\n    local PlaydateTimer = import \"playdate_timer\"\n    local PlaydateSystem = import \"playdate_system\"\n\n    return {\n        name = platformName,\n        Input = PlaydateInput,\n        Graphics = PlaydateGraphics,\n        Audio = PlaydateAudio,\n        Storage = PlaydateStorage,\n        Timer = PlaydateTimer,\n        System = PlaydateSystem\n    }\nend\n\nreturn PlatformConfig\n","project_manager.lua":"-- project_manager.lua\n-- File/project path utilities and backup management extracted from main.lua.\n\nlocal M = {}\n\n-- ===== Pure path utilities =====\n\nfunction M.joinPath(basePath, fileName)\n    local base = tostring(basePath or \"\")\n    local name = tostring(fileName or \"\")\n    if base == \"\" then\n        return name\n    end\n    if base:sub(-1) == \"/\" then\n        return base .. name\n    end\n    return base .. \"/\" .. name\nend\n\nfunction M.basename(path)\n    if type(path) ~= \"string\" or path == \"\" then\n        return nil\n    end\n\n    local normalized = path:gsub(\"\\\\\", \"/\")\n    local name = normalized:match(\"([^/]+)$\")\n    if name and name ~= \"\" then\n        return name\n    end\n\n    return path\nend\n\nfunction M.dirname(path)\n    if type(path) ~= \"string\" or path == \"\" then\n        return nil\n    end\n\n    local normalized = path:gsub(\"\\\\\", \"/\")\n    local dir = normalized:match(\"^(.*)/[^/]*$\")\n    if dir and dir ~= \"\" then\n        return dir\n    end\n\n    return nil\nend\n\nfunction M.normalizeMusicXMLFilename(filename)\n    if type(filename) ~= \"string\" then\n        return nil\n    end\n\n    local cleaned = filename:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n    if cleaned == \"\" then\n        return nil\n    end\n\n    cleaned = cleaned:gsub(\"[\\\\/]\", \"_\")\n    -- Strip any existing extension and add .solfege (package format)\n    cleaned = cleaned:gsub(\"%.[Mm][Uu][Ss][Ii][Cc][Xx][Mm][Ll]$\", \"\"):gsub(\"%.[Xx][Mm][Ll]$\", \"\"):gsub(\"%.solfege$\", \"\")\n    cleaned = cleaned .. \".solfege\"\n\n    return cleaned\nend\n\nfunction M.ensureDirectoryExists(path)\n    local dir = M.dirname(path)\n    if not dir or dir == \"\" then\n        return\n    end\n\n    local quotedDir = \"'\" .. dir:gsub(\"'\", \"'\\\\''\") .. \"'\"\n    os.execute(\"mkdir -p \" .. quotedDir)\nend\n\n-- ===== Backup management =====\n\nlocal MAX_BACKUPS = 10\n\nlocal BACKUP_MONTHS = {\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"}\n\nlocal function shellQuote(path)\n    return \"'\" .. tostring(path):gsub(\"'\", \"'\\\\''\") .. \"'\"\nend\n\nfunction M.backupFileLabel(base)\n    local mo, d, h, mi = base:match(\"sequence_%d%d%d%d(%d%d)(%d%d)_(%d%d)(%d%d)\")\n    if not mo then return base:gsub(\"%.json$\",\"\"):gsub(\"[_%-]+\",\" \") end\n    local month = BACKUP_MONTHS[tonumber(mo)] or mo\n    local hour = tonumber(h)\n    local ampm = hour >= 12 and \"PM\" or \"AM\"\n    hour = hour % 12; if hour == 0 then hour = 12 end\n    return string.format(\"%s %d %d:%02d %s\", month, tonumber(d), hour, tonumber(mi), ampm)\nend\n\nfunction M.getBackupsDirectory(autoSaveMusicXMLFilename, storageRoot)\n    local xmlPath = tostring(autoSaveMusicXMLFilename or \"\")\n    local pkg = xmlPath:match(\"^(.+%.solfege)/[^/]+%.musicxml$\")\n    if pkg and pkg ~= \"\" then\n        return pkg .. \"/versions\"\n    end\n    return (storageRoot and storageRoot ~= \"\") and (storageRoot .. \"/backups\") or nil\nend\n\nfunction M.pruneBackups(autoSaveMusicXMLFilename, storageRoot)\n    local bkDir = M.getBackupsDirectory(autoSaveMusicXMLFilename, storageRoot)\n    if not bkDir then return end\n    local pipe = io.popen(\"ls -1t \" .. shellQuote(bkDir) .. \"/sequence_*.musicxml 2>/dev/null\", \"r\")\n    if not pipe then return end\n    local files = {}\n    for line in pipe:lines() do if line ~= \"\" then files[#files+1] = line end end\n    pipe:close()\n    for i = MAX_BACKUPS + 1, #files do os.remove(files[i]) end\nend\n\nfunction M.createSequenceBackup(autoSaveMusicXMLFilename, storageRoot)\n    local bkDir = M.getBackupsDirectory(autoSaveMusicXMLFilename, storageRoot)\n    if not bkDir then return end\n    local srcFile = tostring(autoSaveMusicXMLFilename or \"\")\n    if srcFile == \"\" then return end\n    local f = io.open(srcFile, \"r\")\n    if not f then return end\n    local content = f:read(\"*a\")\n    f:close()\n    if not content or content == \"\" then return end\n    os.execute(\"mkdir -p \" .. shellQuote(bkDir))\n    local backupPath = bkDir .. \"/sequence_\" .. os.date(\"%Y%m%d_%H%M%S\") .. \".musicxml\"\n    local out = io.open(backupPath, \"w\")\n    if out then\n        out:write(content)\n        out:close()\n    end\n    M.pruneBackups(autoSaveMusicXMLFilename, storageRoot)\nend\n\nreturn M\n","sequencer_core.lua":"local core = {}\n\n-- Patterns are now loaded from template_library instead\n-- template_library will be set by main.lua after both modules load to avoid circular dependency\ncore.templateLibrary = nil\ncore.patterns = nil -- Deprecated: use template_library instead\n\ncore.solfegeScaleModes = {\"major\", \"natural_minor\", \"harmonic_minor\", \"melodic_minor\", \"all\", \"custom\"}\ncore.solfegeNotesByScale = {\n    major = {\"Do\", \"Di\", \"Re\", \"Ri\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\", \"Rest\"},\n    natural_minor = {\"Do\", \"Ra\", \"Re\", \"Me\", \"Mi\", \"Fa\", \"Se\", \"Sol\", \"Le\", \"La\", \"Te\", \"Ti\", \"Do'\", \"Rest\"},\n    harmonic_minor = {\"Do\", \"Ra\", \"Re\", \"Me\", \"Mi\", \"Fa\", \"Se\", \"Sol\", \"Le\", \"La\", \"Te\", \"Ti\", \"Do'\", \"Rest\"},\n    melodic_minor = {\"Do\", \"Ra\", \"Re\", \"Me\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\", \"Rest\"},\n    all = {\"Do\", \"Di\", \"Re\", \"Ri\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\", \"Rest\"},\n    custom = {\"Do\", \"Di\", \"Re\", \"Ri\", \"Mi\", \"Fa\", \"Fi\", \"Sol\", \"Si\", \"La\", \"Li\", \"Ti\", \"Do'\", \"Rest\"},\n}\ncore.solfegeNotes = core.solfegeNotesByScale.major\n\nfunction core.getSolfegeNotes(scaleMode)\n    if scaleMode == \"minor\" then\n        -- Backward compatibility with older saved preferences.\n        return core.solfegeNotesByScale.natural_minor\n    elseif core.solfegeNotesByScale[scaleMode] then\n        return core.solfegeNotesByScale[scaleMode]\n    elseif scaleMode == \"all\" then\n        return core.solfegeNotesByScale.all\n    end\n\n    return core.solfegeNotesByScale.major\nend\ncore.patternTypes = {\n    \"Major Scales\",\n    \"Major Exercises\",\n    \"Pentatonics\",\n    \"Interval Progressions\",\n    \"Interval Training\",\n    \"Directional Intervals\",\n    \"All Octaves\",\n    \"Tonal Center (Anchors)\",\n    \"Tonal Center (Tonic Echoes)\",\n    \"Tonal Center (Melodies)\",\n    \"Tonal Center (Major)\",\n    \"Tonal Center (Minor)\",\n    \"Scales\",\n    \"Modes\",\n    \"Cadences\",\n    \"Arpeggios\",\n    \"Chords\"\n}\ncore.playbackStopOptions = {0, 30, 60, 120, 300, 600, 900, 1200, 1800}\ncore.keyLeadInOptions = {1, 2, 3, 4}\ncore.maxSequences = 16\ncore.maxSteps = 9999\ncore.defaultTempo = 80\ncore.stepBeatsOptions = {0.125, 0.25, 1 / 3, 0.5, 0.75, 1, 1.5, 2, 3, 4}\ncore.timeSignatureOptions = {\n    {numerator = 2, denominator = 4},\n    {numerator = 3, denominator = 4},\n    {numerator = 4, denominator = 4},\n    {numerator = 5, denominator = 4},\n    {numerator = 6, denominator = 8},\n    {numerator = 7, denominator = 8}\n}\n\ncore.noteToHandKey = {\n    [0] = \"do\",\n    [1] = \"do\",\n    [2] = \"re\",\n    [3] = \"re\",\n    [4] = \"mi\",\n    [5] = \"fa\",\n    [6] = \"fa\",\n    [7] = \"sol\",\n    [8] = \"sol\",\n    [9] = \"la\",\n    [10] = \"la\",\n    [11] = \"ti\",\n    [12] = \"do\"\n}\n\ncore.noteFreqs = {\n    [0] = 261.63,  -- C4 (Do)\n    [1] = 277.18,  -- C#4 (Di/Ra)\n    [2] = 293.66,  -- D4 (Re)\n    [3] = 311.13,  -- D#4 (Ri/Me)\n    [4] = 329.63,  -- E4 (Mi)\n    [5] = 349.23,  -- F4 (Fa)\n    [6] = 369.99,  -- F#4 (Fi/Se)\n    [7] = 392.00,  -- G4 (Sol)\n    [8] = 415.30,  -- G#4 (Si/Le)\n    [9] = 440.00,  -- A4 (La)\n    [10] = 466.16, -- A#4 (Li/Te)\n    [11] = 493.88, -- B4 (Ti)\n    [12] = 523.25, -- C5 (Do')\n    [13] = nil     -- Rest (no sound)\n}\n\nfunction core.createState()\n    local state = {\n        showingSettings = false,\n        showingPatternList = false,\n        selectedPattern = 1,\n        selectedPatternListIndex = 1,\n        selectedPatternByType = {1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},\n        patternOctave = 4,\n        patternsPerPage = 9,\n        selectedPatternType = 1,\n        patternTargetSequenceIndex = 1,\n        showingPreferences = false,\n        selectedPreference = 1,\n        showHandsDuringPlayback = false,\n        soundPreviewEnabled = false,\n        soundPreviewOnNavigation = true,\n        acapellaMode = false,\n        singSolfegeMode = false,\n        defaultSingSolfegeMode = false,\n        singSolfegeOctaveOffset = 0,\n        singSolfegeRestStep = false,\n        singSolfegeAutoOctaveHoldMs = 0,\n        singSolfegeAutoOctaveShiftMs = 0,\n        singSolfegeAutoOctaveMessage = nil,\n        singSolfegeAutoOctaveMessageUntil = nil,\n        darkMode = false,\n        pitchRecognitionEnabled = true,\n        micStepRecording = false,\n        midiLiveRecord = false,\n        pitchMatchCents = nil,\n        pitchHoldMs = 0,\n        pitchHoldTargetMs = 0,\n        pitchHoldAchieved = false,\n        micLevel = 0,\n        pitchExpectedNotes = nil,\n        pitchExpectedStep = nil,\n        singSolfegeStepResults = {},\n        rootNote = 0,\n        keyShift = 0,\n        solfegeScale = \"major\",\n        droneEnabled = false,\n        droneNoteSelection = 0,\n        droneOctave = 4,\n        playbackStopSeconds = 0,\n        showingSequenceSelect = false,\n        selectedSequenceOption = 1,\n        showingStepSelect = false,\n        selectedStepOption = 1,\n        stepSelectSequenceIndex = nil,\n        sequences = {{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}},\n        sequenceLengths = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n        sequenceOctaveTranspose = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, -- Octave transpose offset for each sequence (-3 to +3)\n        activeSequenceIndex = 1,\n        sequence = {},\n        sequenceLength = 0,\n        currentStep = 1,\n        playbackPosition = 1,\n        currentPlaybackStep = 1,\n        editCursorFollowsPlayhead = true,   -- auto-follows playhead until user navigates manually\n        editCursorManuallySince = nil,      -- timestamp of last manual navigation\n        currentPlaybackSequenceIndex = 1,\n        selectedNote = 0,\n        currentOctave = 4,\n        isPlaying = false,\n        playKeyBeforeSteps = false,\n        keyLeadInBeats = 2,\n        keyNote = 0,\n        keyOctave = 4,\n        randomizeRootPlayback = false,\n        randomizeOctavePlayback = false, -- Randomly shift octaves during playback for ear training\n        hideNoteNamesDuringPlayback = false,\n        earTrainingRevealAfterPlayback = true,\n        earTrainingMode = false,\n        earTrainingExercise = nil,\n        earTrainingQuestion = nil,\n        earTrainingSelectedOption = 0,\n        earTrainingRevealed = false,\n        earTrainingScore = {correct = 0, total = 0},\n        earTrainingDifficulty = 1,\n        earTrainingDictationInput = \"\",\n        earTrainingDictationCursor = 0,\n        hideNoteNamesDuringSing = false,\n        showNoteNames = false,\n        showRomanNumerals = false,\n        useShapeNotes = false,\n        showNoteLengths = true,\n        showOctaveNumbers = true,\n        showLyrics = true,\n        showSolfegeLyrics = false,\n        hideSteps = true,\n        showSolfegeTextInput = true,\n        showSolfegeButtons = true,\n        solfegeTextMode = \"lyrics\",\n        _lyricsForcesHideSteps = true,\n        _lyricsHideStepsSaved = false,\n        showAddStepButton = true,\n        showToolsRow = true,\n        showPlaybackRow = true,\n        showBarsBeatsRow = true,\n        showBarLines = true,\n        showMicRow = true,\n        audioMuted = false,\n        masterVolume = 1,\n        streamAudioEnabled = false,\n        playbackRootNote = 0,\n        muteStepSequence = false,\n        sequenceMutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n        defaultTempo = core.defaultTempo,\n        tempo = core.defaultTempo,\n        stepBeats = 1,\n        meterNumerator = 4,\n        meterDenominator = 4,\n        stepDuration = (60 / core.defaultTempo) * 1000,\n        headerSelection = 1,\n        headerSelectionMode = false,\n        loopPlayback = false,\n        chordMode = false,\n        chordEditIndex = 1,\n        editMode = \"note\", -- \"note\", \"chord\", or \"delete\"\n        showingTemplateBrowser = false,\n        selectedTemplateCategory = 1,\n        selectedTemplateIndex = 1,\n        showingModeSelect = false,\n        selectedModeOption = 1,\n        sidebarOpen = false,\n        lyricNotesPanelOpen = false,\n        lyricNotesBuffer = \"\",\n        lyricNotesEditingTokenIndex = nil,\n        lyricNotesCursor = nil,\n        showingMidiInPicker = false,\n        midiInPickerSelection = 1,\n        midiInPickerDevices = {},\n        midiInDeviceName = \"\",\n        showingMicInputPicker = false,\n        micInputPickerSelection = 1,\n        micInputPickerDevices = {},\n        micInputDeviceName = \"\",\n        showingGamepadPicker = false,\n        gamepadPickerSelection = 1,\n        gamepadPickerDevices = {},\n        gamepadEnabled = true,\n        gamepadDeviceName = \"\",\n        showingMidiControls = false,\n        editDropdownOpen = false,\n        midiControlsSelection = 1,\n        midiLearnMode = false,\n        midiLearnTarget = nil,\n        midiMappings = {},\n        rowBreakAfterStep = nil,\n        musicXMLFileName = nil,\n        stepLoopStart = nil,\n        stepLoopEnd = nil,\n        cmdChatOpen = true,\n        cmdChatInputBuffer = \"\",\n        cmdChatInputCursor = 0,\n        cmdChatHistory = {},\n        cmdChatHistoryIndex = nil,\n        cmdChatMessages = {},\n        cmdChatScrollOffset = 0,\n        cmdChatBottomH = nil,\n        cmdChatInputActive = false,\n        cmdChatCursorResetTime = 0,\n        cmdChatMuted = false,\n    }\n\n    state.sequence = state.sequences[state.activeSequenceIndex]\n\n    return state\nend\n\nfunction core.updateStepDuration(state)\n    local stepBeats = state.stepBeats or 1\n    state.stepDuration = (60 / state.tempo) * 1000 * stepBeats\nend\n\nfunction core.getValidStepLoopRange(state, sequenceLength)\n    if not state then\n        return nil, nil\n    end\n\n    local loopStart = state.stepLoopStart\n    local loopEnd = state.stepLoopEnd\n    if type(loopStart) ~= \"number\" or type(loopEnd) ~= \"number\" then\n        return nil, nil\n    end\n\n    loopStart = math.floor(loopStart)\n    loopEnd = math.floor(loopEnd)\n    if loopStart < 1 or loopEnd < loopStart then\n        return nil, nil\n    end\n\n    if type(sequenceLength) == \"number\" then\n        if sequenceLength < 1 or loopStart > sequenceLength or loopEnd > sequenceLength then\n            return nil, nil\n        end\n    end\n\n    return loopStart, loopEnd\nend\n\nlocal function clearInvalidStepLoopRange(state)\n    local loopStart, loopEnd = core.getValidStepLoopRange(state)\n    if not loopStart or not loopEnd then\n        state.stepLoopStart = nil\n        state.stepLoopEnd = nil\n    end\nend\n\nlocal function adjustStepLoopRangeForInsert(state, stepIndex)\n    local loopStart, loopEnd = core.getValidStepLoopRange(state)\n    if not loopStart or not loopEnd then\n        clearInvalidStepLoopRange(state)\n        return\n    end\n\n    if stepIndex <= loopStart then\n        state.stepLoopStart = loopStart + 1\n        state.stepLoopEnd = loopEnd + 1\n    elseif stepIndex <= loopEnd then\n        state.stepLoopEnd = loopEnd + 1\n    end\nend\n\nlocal function adjustStepLoopRangeForDelete(state, stepIndex)\n    local loopStart, loopEnd = core.getValidStepLoopRange(state)\n    if not loopStart or not loopEnd then\n        clearInvalidStepLoopRange(state)\n        return\n    end\n\n    if stepIndex < loopStart then\n        state.stepLoopStart = loopStart - 1\n        state.stepLoopEnd = loopEnd - 1\n    elseif stepIndex == loopStart and stepIndex == loopEnd then\n        state.stepLoopStart = nil\n        state.stepLoopEnd = nil\n    elseif stepIndex <= loopEnd then\n        state.stepLoopStart = loopStart\n        state.stepLoopEnd = loopEnd - 1\n        if state.stepLoopEnd < state.stepLoopStart then\n            state.stepLoopStart = nil\n            state.stepLoopEnd = nil\n        end\n    end\nend\n\nfunction core.setTempo(state, newTempo)\n    state.tempo = math.max(1, math.min(newTempo, 990))\n    core.updateStepDuration(state)\nend\n\nfunction core.setStepBeats(state, stepBeats)\n    if type(stepBeats) ~= \"number\" or stepBeats <= 0 then\n        stepBeats = 1\n    end\n    state.stepBeats = stepBeats\n    core.updateStepDuration(state)\nend\n\nfunction core.getStepBeatsOptionIndex(stepBeats)\n    local closestIndex = 1\n    local closestDiff = math.huge\n\n    for i, option in ipairs(core.stepBeatsOptions) do\n        local diff = math.abs(option - stepBeats)\n        if diff < 0.001 then\n            return i\n        end\n\n        if diff < closestDiff then\n            closestDiff = diff\n            closestIndex = i\n        end\n    end\n\n    return closestIndex\nend\n\nfunction core.getStepBeatsLabel(stepBeats)\n    if stepBeats and stepBeats >= 4 and math.abs(stepBeats % 4) < 0.001 then\n        local bars = math.floor((stepBeats / 4) + 0.5)\n        if bars == 1 then\n            return \"1 bar (4 beats)\"\n        end\n        return tostring(bars) .. \" bars (\" .. tostring(stepBeats) .. \" beats)\"\n    end\n\n    local namedDurations = {\n        {beats = 4, label = \"1 bar (4 beats)\"},\n        {beats = 3, label = \"Dotted half note (3 beats)\"},\n        {beats = 2, label = \"Half note (2 beats)\"},\n        {beats = 1.5, label = \"Dotted quarter note (1 1/2 beats)\"},\n        {beats = 1, label = \"Quarter note (1 beat)\"},\n        {beats = 0.75, label = \"Dotted eighth note (3/4 beat)\"},\n        {beats = 0.5, label = \"Eighth note (1/2 beat)\"},\n        {beats = 1 / 3, label = \"Eighth-note triplet (1/3 beat)\"},\n        {beats = 0.25, label = \"16th note (1/4 beat)\"},\n        {beats = 0.125, label = \"32nd note (1/8 beat)\"}\n    }\n\n    for _, duration in ipairs(namedDurations) do\n        if math.abs(stepBeats - duration.beats) < 0.001 then\n            return duration.label\n        end\n    end\n\n    return tostring(stepBeats) .. \" beats\"\nend\n\n-- Short label for use in header (e.g. \"1/4\", \"1/8\", \"1/8t\", \"1/16\")\nfunction core.getStepBeatsShortLabel(stepBeats)\n    if     math.abs(stepBeats - 4)     < 0.001 then return \"1/1\"\n    elseif math.abs(stepBeats - 3)     < 0.001 then return \"1/2.\"\n    elseif math.abs(stepBeats - 2)     < 0.001 then return \"1/2\"\n    elseif math.abs(stepBeats - 1.5)   < 0.001 then return \"1/4.\"\n    elseif math.abs(stepBeats - 1)     < 0.001 then return \"1/4\"\n    elseif math.abs(stepBeats - 0.75)  < 0.001 then return \"1/8.\"\n    elseif math.abs(stepBeats - 0.5)   < 0.001 then return \"1/8\"\n    elseif math.abs(stepBeats - 1/3)   < 0.001 then return \"1/8t\"\n    elseif math.abs(stepBeats - 0.25)  < 0.001 then return \"1/16\"\n    elseif math.abs(stepBeats - 0.125) < 0.001 then return \"1/32\"\n    else return string.format(\"%.3g\", stepBeats)\n    end\nend\n\nfunction core.getTimeSignatureLabel(numerator, denominator)\n    local n = tonumber(numerator) or 4\n    local d = tonumber(denominator) or 4\n    return tostring(n) .. \"/\" .. tostring(d)\nend\n\n-- Returns how many steps fit in one bar given time signature and step beat length.\n-- Returns nil if the result is not a whole number (can't draw clean bar lines).\nfunction core.getStepsPerBar(meterNumerator, meterDenominator, stepBeats)\n    if not meterNumerator or not meterDenominator or not stepBeats\n       or meterDenominator <= 0 or stepBeats <= 0 then\n        return nil\n    end\n    -- Quarter-note beats per bar: e.g. 4/4 -> 4, 3/4 -> 3, 6/8 -> 3\n    local quarterBeatsPerBar = meterNumerator * (4.0 / meterDenominator)\n    local spb = quarterBeatsPerBar / stepBeats\n    local rounded = math.floor(spb + 0.5)\n    if math.abs(spb - rounded) < 0.001 and rounded >= 1 then\n        return rounded\n    end\n    return nil\nend\n\n-- Returns integer steps per beat when stepBeats < 1 (e.g. 0.5 → 2 steps/beat).\n-- Returns nil for stepBeats >= 1 (step equals or exceeds one beat).\nfunction core.getStepsPerBeat(stepBeats)\n    if not stepBeats or stepBeats <= 0 or stepBeats >= 1 then return nil end\n    local spb = 1.0 / stepBeats\n    local rounded = math.floor(spb + 0.5)\n    if math.abs(spb - rounded) < 0.001 and rounded >= 2 then\n        return rounded\n    end\n    return nil\nend\n\n-- Returns a \"Bar.Beat\" display string for a given step (Logic Pro style position counter).\n-- beatInBar formula: floor(stepInBar * stepBeats * meterDenominator / 4) + 1\n-- Falls back to just the step number when no clean bar grid exists.\nfunction core.getBarBeatDisplay(step, meterNumerator, meterDenominator, stepBeats)\n    step = step or 1\n    local stepsPerBar = core.getStepsPerBar(meterNumerator, meterDenominator, stepBeats)\n    if not stepsPerBar then\n        return tostring(step)\n    end\n    local step0 = step - 1\n    local bar = math.floor(step0 / stepsPerBar) + 1\n    local stepInBar = step0 % stepsPerBar\n    local beatInBar = math.floor(stepInBar * (stepBeats or 1) * (meterDenominator or 4) / 4) + 1\n    return bar .. \".\" .. beatInBar\nend\n\n-- Returns the first step (1-indexed) of the given bar number.\n-- Falls back to treating the bar number as a step number when no clean bar grid exists.\nfunction core.barToFirstStep(bar, meterNumerator, meterDenominator, stepBeats)\n    bar = math.max(1, math.floor(bar or 1))\n    local stepsPerBar = core.getStepsPerBar(meterNumerator, meterDenominator, stepBeats)\n    if not stepsPerBar then\n        return bar\n    end\n    return (bar - 1) * stepsPerBar + 1\nend\n\nfunction core.getTimeSignatureOptionIndex(numerator, denominator)\n    local n = tonumber(numerator) or 4\n    local d = tonumber(denominator) or 4\n    local fallback = 1\n\n    for i, signature in ipairs(core.timeSignatureOptions) do\n        if signature.numerator == 4 and signature.denominator == 4 then\n            fallback = i\n        end\n        if signature.numerator == n and signature.denominator == d then\n            return i\n        end\n    end\n\n    return fallback\nend\n\nfunction core.setTimeSignature(state, numerator, denominator)\n    local index = core.getTimeSignatureOptionIndex(numerator, denominator)\n    local signature = core.timeSignatureOptions[index]\n    state.meterNumerator = signature.numerator\n    state.meterDenominator = signature.denominator\nend\n\nfunction core.getPlaybackStopLabel(seconds)\n    if seconds <= 0 then\n        return \"OFF\"\n    end\n    if seconds < 60 then\n        return seconds .. \"s\"\n    end\n    return (seconds / 60) .. \"m\"\nend\n\nfunction core.getPlaybackStopOptionIndex(seconds)\n    for i, option in ipairs(core.playbackStopOptions) do\n        if option == seconds then\n            return i\n        end\n    end\n    return 1\nend\n\nfunction core.getKeyLeadInOptionIndex(beats)\n    for i, option in ipairs(core.keyLeadInOptions) do\n        if option == beats then\n            return i\n        end\n    end\n    return 1\nend\n\nlocal function getStateSolfegeNotes(state)\n    return core.getSolfegeNotes(state and state.solfegeScale or \"major\")\nend\n\nlocal naturalMinorSolfegeOverrides = {\n    [3] = \"Me\",\n    [8] = \"Le\",\n    [10] = \"Te\"\n}\n\nlocal function getTemplateSolfegeLabel(template, noteIndex)\n    if template and template.patternType == \"natural_minor\" and naturalMinorSolfegeOverrides[noteIndex] then\n        return naturalMinorSolfegeOverrides[noteIndex]\n    end\n\n    return core.solfegeNotes[noteIndex + 1] or \"--\"\nend\n\n-- Helper to convert template to pattern format for backward compatibility\nlocal function templateToPattern(template)\n    local pattern = {\n        name = template.name,\n        notes = {},\n        solfege = {}\n    }\n\n    -- Extract notes from first sequence\n    if template.sequences and template.sequences[1] then\n        for _, step in ipairs(template.sequences[1]) do\n            table.insert(pattern.notes, step.note)\n        end\n    end\n\n    -- Generate solfege from notes\n    for _, noteIndex in ipairs(pattern.notes) do\n        table.insert(pattern.solfege, getTemplateSolfegeLabel(template, noteIndex))\n    end\n\n    return pattern\nend\n\n-- Get templates for a pattern type and convert to pattern format\nlocal function getTemplatesAsPatterns(category)\n    if not core.templateLibrary then\n        error(\"templateLibrary not initialized - main.lua should set core.templateLibrary\")\n    end\n    local templates = core.templateLibrary.getTemplatesByCategory(category)\n    local patterns = {}\n    for _, template in ipairs(templates) do\n        table.insert(patterns, templateToPattern(template))\n    end\n    return patterns\nend\n\nfunction core.getCurrentPatternList(state)\n    if state.selectedPatternType == 1 then\n        return getTemplatesAsPatterns(\"Major Scales\")\n    elseif state.selectedPatternType == 2 then\n        return getTemplatesAsPatterns(\"Major Exercises\")\n    elseif state.selectedPatternType == 3 then\n        return getTemplatesAsPatterns(\"Pentatonics\")\n    elseif state.selectedPatternType == 4 then\n        return getTemplatesAsPatterns(\"Interval Progressions\")\n    elseif state.selectedPatternType == 5 then\n        return getTemplatesAsPatterns(\"Interval Training\")\n    elseif state.selectedPatternType == 6 then\n        return getTemplatesAsPatterns(\"Directional Intervals\")\n    elseif state.selectedPatternType == 7 then\n        return getTemplatesAsPatterns(\"All Octaves\")\n    elseif state.selectedPatternType == 8 then\n        -- Tonal Center Anchors - filter tonal center templates\n        local allTonal = getTemplatesAsPatterns(\"Tonal Center\")\n        local anchors = {}\n        for _, p in ipairs(allTonal) do\n            if p.name:find(\"Anchor\") or p.name:find(\"Do%-Re%-Mi\") or p.name:find(\"Do%-Mi%-Sol\") or p.name:find(\"Do%-Fa\") or p.name:find(\"Do%-Sol\") then\n                table.insert(anchors, p)\n            end\n        end\n        return anchors\n    elseif state.selectedPatternType == 9 then\n        -- Tonal Center Tonic Echoes\n        local allTonal = getTemplatesAsPatterns(\"Tonal Center\")\n        local echoes = {}\n        for _, p in ipairs(allTonal) do\n            if p.name:find(\"Echo\") or (p.name:find(\"Do%-\") and p.name:find(\"%-Do\") and not p.name:find(\"Mi%-Re%-Do\")) then\n                table.insert(echoes, p)\n            end\n        end\n        return echoes\n    elseif state.selectedPatternType == 10 then\n        -- Tonal Center Melodies\n        local allTonal = getTemplatesAsPatterns(\"Tonal Center\")\n        local melodies = {}\n        for _, p in ipairs(allTonal) do\n            if not (p.name:find(\"Anchor\") or p.name:find(\"Echo\") or p.name:find(\"(Major)\") or p.name:find(\"(Minor)\")) then\n                table.insert(melodies, p)\n            end\n        end\n        return melodies\n    elseif state.selectedPatternType == 11 then\n        -- Tonal Center Major\n        local allTonal = getTemplatesAsPatterns(\"Tonal Center\")\n        local major = {}\n        for _, p in ipairs(allTonal) do\n            if p.name:find(\"(Major)\") then\n                table.insert(major, p)\n            end\n        end\n        return major\n    elseif state.selectedPatternType == 12 then\n        -- Tonal Center Minor\n        local allTonal = getTemplatesAsPatterns(\"Tonal Center\")\n        local minor = {}\n        for _, p in ipairs(allTonal) do\n            if p.name:find(\"(Minor)\") then\n                table.insert(minor, p)\n            end\n        end\n        return minor\n    elseif state.selectedPatternType == 13 then\n        -- Scales\n        local allScales = getTemplatesAsPatterns(\"Scales & Modes\")\n        local scales = {}\n        for _, p in ipairs(allScales) do\n            if not (p.name:find(\"Mode\") or p.name:find(\"Cadence\") or p.name:find(\"V%-I\") or p.name:find(\"IV%-I\")) then\n                table.insert(scales, p)\n            end\n        end\n        return scales\n    elseif state.selectedPatternType == 14 then\n        -- Modes\n        local allScales = getTemplatesAsPatterns(\"Scales & Modes\")\n        local modes = {}\n        for _, p in ipairs(allScales) do\n            if p.name:find(\"Mode\") then\n                table.insert(modes, p)\n            end\n        end\n        return modes\n    elseif state.selectedPatternType == 15 then\n        -- Cadences\n        local allScales = getTemplatesAsPatterns(\"Scales & Modes\")\n        local cadences = {}\n        for _, p in ipairs(allScales) do\n            if p.name:find(\"Cadence\") or p.name:find(\"V%-I\") or p.name:find(\"IV%-I\") or p.name:find(\"I%-IV\") or p.name:find(\"ii%-V\") then\n                table.insert(cadences, p)\n            end\n        end\n        return cadences\n    elseif state.selectedPatternType == 16 then\n        -- Arpeggios\n        local allArps = getTemplatesAsPatterns(\"Arpeggios & Chords\")\n        local arpeggios = {}\n        for _, p in ipairs(allArps) do\n            if p.name:find(\"Arpeggio\") then\n                table.insert(arpeggios, p)\n            end\n        end\n        return arpeggios\n    else\n        -- Chords\n        local allArps = getTemplatesAsPatterns(\"Arpeggios & Chords\")\n        local chords = {}\n        for _, p in ipairs(allArps) do\n            if not p.name:find(\"Arpeggio\") then\n                table.insert(chords, p)\n            end\n        end\n        return chords\n    end\nend\n\nfunction core.loadPattern(state, patternIndex)\n    local patternList = core.getCurrentPatternList(state)\n    local pattern = patternList[patternIndex]\n    if not pattern then\n        return false\n    end\n\n    for i = 1, core.maxSteps do\n        state.sequence[i] = nil\n    end\n\n    -- Use patternOctave from state (user-adjustable in pattern menu)\n    local loadOctave = state.patternOctave or 4\n    state.sequenceLength = 0\n\n    if state.selectedPatternType == 8 then\n        -- All Octaves pattern: spread across multiple octaves starting from loadOctave\n        for i, noteIndex in ipairs(pattern.notes) do\n            if i <= core.maxSteps then\n                local groupNumber = math.floor((i - 1) / 3)\n                local stepOctave = math.min(loadOctave + groupNumber, 7)\n                state.sequence[i] = {\n                    note = noteIndex,\n                    octave = stepOctave\n                }\n                state.sequenceLength = i\n            end\n        end\n    else\n        -- Regular patterns: all notes at the same octave\n        for i, noteIndex in ipairs(pattern.notes) do\n            if i <= core.maxSteps then\n                state.sequence[i] = {\n                    note = noteIndex,\n                    octave = loadOctave\n                }\n                state.sequenceLength = i\n            end\n        end\n    end\n\n    state.currentStep = 1\n    return true\nend\n\nfunction core.syncActiveSequenceState(state)\n    state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n    state.sequences[state.activeSequenceIndex] = state.sequence\nend\n\nfunction core.setActiveSequence(state, index, skipSync)\n    if index < 1 or index > core.maxSequences then\n        return false\n    end\n\n    -- Only sync the old active sequence if we're not skipping\n    -- (skipSync is used when loading templates where the array state is already correct)\n    if not skipSync then\n        core.syncActiveSequenceState(state)\n    end\n\n    if not state.sequences[index] then\n        state.sequences[index] = {}\n        state.sequenceLengths[index] = 0\n    end\n    if not state.sequenceMutes then\n        state.sequenceMutes = {}\n    end\n    if state.sequenceMutes[index] == nil then\n        state.sequenceMutes[index] = false\n    end\n    if state.sequenceOctaveTranspose[index] == nil then\n        state.sequenceOctaveTranspose[index] = 0\n    end\n    state.activeSequenceIndex = index\n    state.sequence = state.sequences[state.activeSequenceIndex]\n    state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\n    state.currentStep = math.min(math.max(1, state.currentStep), math.max(state.sequenceLength, 1))\n    state.patternTargetSequenceIndex = index\n    return true\nend\n\nfunction core.clearSequence(state)\n    for i = 1, core.maxSteps do\n        state.sequence[i] = nil\n    end\n    state.sequenceLength = 0\n    state.currentStep = 1\nend\n\nfunction core.copySequenceData(source, length)\n    local copy = {}\n    for i = 1, length do\n        if source[i] then\n            if source[i].notes then\n                -- Copy chord data\n                copy[i] = {\n                    notes        = {},\n                    length       = source[i].length,\n                    muted        = source[i].muted,\n                    lyric        = source[i].lyric,\n                    gate         = source[i].gate,\n                    paragraphEnd = source[i].paragraphEnd,\n                }\n                for j = 1, #source[i].notes do\n                    copy[i].notes[j] = {\n                        note = source[i].notes[j].note,\n                        octave = source[i].notes[j].octave\n                    }\n                end\n            else\n                -- Copy single note data\n                copy[i] = {\n                    note         = source[i].note,\n                    octave       = source[i].octave,\n                    length       = source[i].length,\n                    muted        = source[i].muted,\n                    lyric        = source[i].lyric,\n                    gate         = source[i].gate,\n                    paragraphEnd = source[i].paragraphEnd,\n                }\n            end\n        end\n    end\n    return copy\nend\n\nfunction core.addNewSequence(state)\n    for i = 1, core.maxSequences do\n        if not state.sequences[i] then\n            state.sequences[i] = {}\n            state.sequenceLengths[i] = 0\n            state.sequenceMutes[i] = false\n            state.sequenceOctaveTranspose[i] = 0\n            core.setActiveSequence(state, i)\n            core.clearSequence(state)\n            return true\n        end\n    end\n    return false\nend\n\nfunction core.swapSequences(state, indexA, indexB)\n    if indexA == indexB then return false end\n    if indexA < 1 or indexA > core.maxSequences then return false end\n    if indexB < 1 or indexB > core.maxSequences then return false end\n\n    -- Swap all per-sequence arrays\n    state.sequences[indexA], state.sequences[indexB] = state.sequences[indexB], state.sequences[indexA]\n    state.sequenceLengths[indexA], state.sequenceLengths[indexB] = state.sequenceLengths[indexB], state.sequenceLengths[indexA]\n    state.sequenceMutes[indexA], state.sequenceMutes[indexB] = state.sequenceMutes[indexB], state.sequenceMutes[indexA]\n    state.sequenceOctaveTranspose[indexA], state.sequenceOctaveTranspose[indexB] = state.sequenceOctaveTranspose[indexB], state.sequenceOctaveTranspose[indexA]\n\n    -- Update activeSequenceIndex if it was one of the swapped sequences\n    if state.activeSequenceIndex == indexA then\n        state.activeSequenceIndex = indexB\n    elseif state.activeSequenceIndex == indexB then\n        state.activeSequenceIndex = indexA\n    end\n\n    -- Re-sync active sequence reference\n    state.sequence = state.sequences[state.activeSequenceIndex]\n    state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\n\n    return true\nend\n\nfunction core.deleteSequence(state, index)\n    if not state.sequences[index] then\n        return false\n    end\n\n    -- Delete the sequence\n    state.sequences[index] = nil\n    state.sequenceLengths[index] = 0\n    state.sequenceMutes[index] = false\n    state.sequenceOctaveTranspose[index] = 0\n\n    -- If we deleted the active sequence, switch to the first available sequence\n    if state.activeSequenceIndex == index then\n        local newIndex = 1\n        for i = 1, core.maxSequences do\n            if state.sequences[i] then\n                newIndex = i\n                break\n            end\n        end\n        core.setActiveSequence(state, newIndex)\n    end\n\n    return true\nend\n\nfunction core.getSequenceMenuItems(state)\n    core.syncActiveSequenceState(state)\n    local items = {}\n    for i = 1, core.maxSequences do\n        if state.sequences[i] then\n            local length = state.sequenceLengths[i] or 0\n            local label = \"Pattern \" .. i .. \" (Steps: \" .. length .. \")\"\n\n            -- Add octave transpose to label if not zero\n            local octaveTranspose = state.sequenceOctaveTranspose[i] or 0\n            if octaveTranspose ~= 0 then\n                local transposeSign = octaveTranspose > 0 and \"+\" or \"\"\n                label = label .. \" Oct:\" .. transposeSign .. octaveTranspose\n            end\n\n            if i == state.activeSequenceIndex then\n                label = label .. \" [Active]\"\n            end\n            if state.sequenceMutes[i] then\n                label = label .. \" [Muted]\"\n            end\n            local previewNotes = {}\n            local solfegeNotes = getStateSolfegeNotes(state)\n            for stepIndex = 1, 8 do\n                local stepData = state.sequences[i][stepIndex]\n                if stepData and stepData.note then\n                    local noteIndex = stepData.note + 1\n                    table.insert(previewNotes, solfegeNotes[noteIndex] or \"--\")\n                else\n                    table.insert(previewNotes, \"--\")\n                end\n            end\n            table.insert(items, {\n                label = label,\n                preview = table.concat(previewNotes, \" \"),\n                enabled = true,\n                sequenceIndex = i,\n                action = function()\n                    core.setActiveSequence(state, i)\n                    state.showingSequenceSelect = false\n                    state.selectedSequenceOption = 1\n                end\n            })\n        end\n    end\n    if #items < core.maxSequences then\n        table.insert(items, {\n            label = \"Add New Pattern\",\n            preview = nil,\n            enabled = true,\n            action = function()\n                core.addNewSequence(state)\n                state.showingSequenceSelect = false\n                state.selectedSequenceOption = 1\n            end\n        })\n    end\n    return items\nend\n\n-- Chord helper functions\nfunction core.isChord(stepData)\n    return stepData and stepData.notes and #stepData.notes > 0\nend\n\nfunction core.getChordNotes(stepData)\n    if core.isChord(stepData) then\n        return stepData.notes\n    elseif stepData and stepData.note ~= nil then\n        return {{note = stepData.note, octave = stepData.octave}}\n    end\n    return {}\nend\n\nfunction core.getStepSolfegeLyric(state, stepData)\n    if type(stepData) ~= \"table\" then\n        return nil\n    end\n\n    local noteIndex = nil\n    if stepData.note ~= nil then\n        noteIndex = stepData.note\n    elseif stepData.notes and stepData.notes[1] and stepData.notes[1].note ~= nil then\n        noteIndex = stepData.notes[1].note\n    end\n\n    if noteIndex == nil then\n        return nil\n    end\n\n    local solfegeNotes = getStateSolfegeNotes(state)\n    local label = solfegeNotes[noteIndex + 1]\n    if type(label) ~= \"string\" or label == \"\" then\n        return nil\n    end\n\n    return label:gsub(\"'\", \"\")\nend\n\nfunction core.getStepLyricForView(state, stepData)\n    if not stepData then\n        return \"\"\n    end\n\n    if stepData.lyric == \"_\" then\n        return \"_\"\n    end\n\n    if state and state.showSolfegeLyrics then\n        return core.getStepSolfegeLyric(state, stepData) or (stepData.lyric or \"\")\n    end\n\n    return stepData.lyric or \"\"\nend\n\n-- Roman numeral analysis helpers\nlocal _SCALE_DEGREES = {\n    major          = {[0]=1,[2]=2,[4]=3,[5]=4,[7]=5,[9]=6,[11]=7},\n    natural_minor  = {[0]=1,[2]=2,[3]=3,[5]=4,[7]=5,[8]=6,[10]=7},\n    harmonic_minor = {[0]=1,[2]=2,[3]=3,[5]=4,[7]=5,[8]=6,[11]=7},\n    melodic_minor  = {[0]=1,[2]=2,[3]=3,[5]=4,[7]=5,[9]=6,[11]=7},\n    all            = {[0]=1,[1]=1,[2]=2,[3]=2,[4]=3,[5]=4,[6]=4,[7]=5,[8]=5,[9]=6,[10]=6,[11]=7},\n}\nlocal _ROMAN = {\"I\",\"II\",\"III\",\"IV\",\"V\",\"VI\",\"VII\"}\nlocal _CHORD_SIGS = {\n    [\"0,4,7\"]    ={q=\"maj\", suf=\"\"},\n    [\"0,3,7\"]    ={q=\"min\", suf=\"\"},\n    [\"0,3,6\"]    ={q=\"dim\", suf=\"o\"},\n    [\"0,4,8\"]    ={q=\"aug\", suf=\"+\"},\n    [\"0,4,7,11\"] ={q=\"maj\", suf=\"M7\"},\n    [\"0,4,7,10\"] ={q=\"maj\", suf=\"7\"},\n    [\"0,3,7,10\"] ={q=\"min\", suf=\"7\"},\n    [\"0,3,6,10\"] ={q=\"min\", suf=\"\\xc3\\xb87\"},\n    [\"0,3,6,9\"]  ={q=\"dim\", suf=\"o7\"},\n    [\"0,3,7,11\"] ={q=\"min\", suf=\"M7\"},\n}\n\n-- Returns a Roman numeral string for a chord (e.g. \"I\", \"ii\", \"V7\", \"viio\").\n-- notes: array of {note=<0-11>, octave=<2-8>} (note = semitones above tonal root)\n-- solfegeScale: \"major\"|\"natural_minor\"|\"harmonic_minor\"|\"melodic_minor\"|\"all\"\n-- Returns nil if notes < 2 or chord is unrecognised.\nfunction core.getChordRomanNumeral(notes, solfegeScale)\n    if not notes or #notes < 2 then return nil end\n    local scaleMap = _SCALE_DEGREES[solfegeScale] or _SCALE_DEGREES.major\n\n    -- collect unique pitch classes\n    local seen = {}\n    local pcs = {}\n    for _, n in ipairs(notes) do\n        local pc = n.note % 12\n        if not seen[pc] then seen[pc] = true; pcs[#pcs+1] = pc end\n    end\n    table.sort(pcs)\n    if #pcs < 2 then return nil end\n\n    -- try each pitch class as root candidate (stacking-in-thirds method)\n    local root, chordInfo\n    for _, cand in ipairs(pcs) do\n        local ivs = {}\n        for _, pc in ipairs(pcs) do ivs[#ivs+1] = (pc - cand + 12) % 12 end\n        table.sort(ivs)\n        local sig = table.concat(ivs, \",\")\n        if _CHORD_SIGS[sig] then\n            root = cand\n            chordInfo = _CHORD_SIGS[sig]\n            break\n        end\n    end\n    if not root then return nil end\n\n    -- find scale degree + accidental\n    local degree, accidental = scaleMap[root], \"\"\n    if not degree then\n        for delta = 1, 6 do\n            local above = (root + delta) % 12\n            if scaleMap[above] then degree = scaleMap[above]; accidental = \"b\"; break end\n        end\n        if not degree then return nil end\n    end\n\n    -- build string\n    local roman = _ROMAN[degree]\n    local q = chordInfo.q\n    if q == \"min\" or q == \"dim\" then roman = roman:lower() end\n    return accidental .. roman .. chordInfo.suf\nend\n\nfunction core.addNoteToChord(state, note, octave)\n    local step = state.sequence[state.currentStep]\n    if not step then\n        -- Create new chord with single note\n        state.sequence[state.currentStep] = {\n            notes = {{note = note, octave = octave}},\n            lyric = nil,\n        }\n        if state.currentStep == state.sequenceLength + 1 then\n            state.sequenceLength = state.sequenceLength + 1\n            state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n        end\n        return true\n    end\n\n    -- Convert single note to chord if needed\n    if step.note ~= nil and not step.notes then\n        step.notes = {{note = step.note, octave = step.octave}}\n        step.note = nil\n        step.octave = nil\n    end\n\n    -- Check if we already have 3 notes (max)\n    if step.notes and #step.notes >= 3 then\n        return false\n    end\n\n    -- Add the note to the chord\n    if not step.notes then\n        step.notes = {}\n    end\n    table.insert(step.notes, {note = note, octave = octave})\n    return true\nend\n\nfunction core.removeNoteFromChord(state, index)\n    local step = state.sequence[state.currentStep]\n    if not step or not step.notes then\n        return false\n    end\n\n    if index < 1 or index > #step.notes then\n        return false\n    end\n\n    table.remove(step.notes, index)\n\n    -- If only one note left, convert back to single note\n    if #step.notes == 1 then\n        local singleNote = step.notes[1]\n        step.note = singleNote.note\n        step.octave = singleNote.octave\n        step.notes = nil\n    elseif #step.notes == 0 then\n        -- Remove step entirely if no notes left\n        state.sequence[state.currentStep] = nil\n    end\n\n    return true\nend\n\nfunction core.updateChordNote(state, index, note, octave)\n    local step = state.sequence[state.currentStep]\n    if not step or not step.notes then\n        return false\n    end\n\n    if index < 1 or index > #step.notes then\n        return false\n    end\n\n    step.notes[index].note = note\n    step.notes[index].octave = octave\n    return true\nend\n\n-- Step select screen: get menu items for steps in a given sequence\nfunction core.getStepMenuItems(state, seqIndex)\n    core.syncActiveSequenceState(state)\n    if not seqIndex or not state.sequences[seqIndex] then\n        return {}\n    end\n    local seq = state.sequences[seqIndex]\n    local length = state.sequenceLengths[seqIndex] or 0\n    local items = {}\n    local solfegeNotes = getStateSolfegeNotes(state)\n    table.insert(items, {\n        label = \">> Edit Pattern\",\n        isEditAction = true,\n        sequenceIndex = seqIndex,\n    })\n    table.insert(items, {\n        label = \">> Sing Solfege\",\n        isSingAction = true,\n        sequenceIndex = seqIndex,\n    })\n    table.insert(items, {\n        label = \"Mute: \" .. (state.sequenceMutes[seqIndex] and \"ON\" or \"OFF\"),\n        isSequenceMuteToggle = true,\n        sequenceIndex = seqIndex,\n    })\n    table.insert(items, {\n        label = \">> Delete Pattern\",\n        isDeleteSequence = true,\n        sequenceIndex = seqIndex,\n    })\n\n    for i = 1, length do\n        local stepData = seq[i]\n        local label\n        if not stepData then\n            label = \"Step \" .. i .. \": (empty)\"\n        elseif stepData.note == 13 then\n            label = \"Step \" .. i .. \": Rest\"\n        elseif core.isChord(stepData) then\n            local noteNames = {}\n            for _, n in ipairs(stepData.notes) do\n                table.insert(noteNames, solfegeNotes[n.note + 1] or \"--\")\n            end\n            label = \"Step \" .. i .. \": \" .. table.concat(noteNames, \"+\")\n        elseif stepData.note ~= nil then\n            local noteName = solfegeNotes[stepData.note + 1] or \"--\"\n            label = \"Step \" .. i .. \": \" .. noteName .. \" (Oct \" .. (stepData.octave or 4) .. \")\"\n        else\n            label = \"Step \" .. i .. \": (empty)\"\n        end\n        table.insert(items, {\n            label = label,\n            stepIndex = i,\n            hasNote = stepData ~= nil,\n            isRest = stepData and stepData.note == 13,\n        })\n    end\n    return items\nend\n\n-- Delete a single step from a sequence (shifts subsequent steps down)\nfunction core.deleteStep(state, seqIndex, stepIndex)\n    if not seqIndex or not state.sequences[seqIndex] then\n        return false\n    end\n    local seq = state.sequences[seqIndex]\n    local length = state.sequenceLengths[seqIndex] or 0\n    if stepIndex < 1 or stepIndex > length then\n        return false\n    end\n    -- Shift steps down\n    for i = stepIndex, length - 1 do\n        seq[i] = seq[i + 1]\n    end\n    seq[length] = nil\n    state.sequenceLengths[seqIndex] = length - 1\n    if seqIndex == state.activeSequenceIndex then\n        adjustStepLoopRangeForDelete(state, stepIndex)\n    end\n    -- Sync active sequence if needed\n    if seqIndex == state.activeSequenceIndex then\n        state.sequenceLength = state.sequenceLengths[seqIndex]\n    end\n    return true\nend\n\n-- Insert a step at stepIndex, shifting existing steps from stepIndex onward up by one\nfunction core.insertStep(state, seqIndex, stepIndex, stepData)\n    if not seqIndex or not state.sequences[seqIndex] then return false end\n    local seq = state.sequences[seqIndex]\n    local length = state.sequenceLengths[seqIndex] or 0\n    stepIndex = math.max(1, math.min(stepIndex, length + 1))\n    -- Shift steps up to make room\n    for i = length, stepIndex, -1 do\n        seq[i + 1] = seq[i]\n    end\n    seq[stepIndex] = stepData\n    state.sequenceLengths[seqIndex] = length + 1\n    if seqIndex == state.activeSequenceIndex then\n        adjustStepLoopRangeForInsert(state, stepIndex)\n    end\n    if seqIndex == state.activeSequenceIndex then\n        state.sequenceLength = state.sequenceLengths[seqIndex]\n        state.sequence = state.sequences[seqIndex]\n    end\n    return true\nend\n\n-- Move a step to a new index within a sequence (shifts surrounding steps).\n-- For stretched steps (length > 1), moves the entire block (step + nil continuations).\nfunction core.moveStep(state, seqIndex, fromIndex, toIndex)\n    if not seqIndex or not state.sequences[seqIndex] then\n        return false\n    end\n\n    local seq = state.sequences[seqIndex]\n    local length = state.sequenceLengths[seqIndex] or 0\n    if fromIndex < 1 or fromIndex > length then\n        return false\n    end\n    if toIndex < 1 or toIndex > length then\n        return false\n    end\n    if fromIndex == toIndex then\n        return false\n    end\n\n    local movingStep = seq[fromIndex]\n    if not movingStep then\n        return false\n    end\n\n    local stepLen = movingStep.length or 1\n    local stepSlots = math.ceil(stepLen)  -- integer slot count (handles fractional lengths)\n\n    if stepSlots > 1 then\n        -- Clamp toIndex so the whole block fits within the sequence\n        toIndex = math.max(1, math.min(toIndex, length - stepSlots + 1))\n        if fromIndex == toIndex then return false end\n\n        -- Save the block (step + nil continuations)\n        local block = {}\n        for i = 1, stepSlots do block[i] = seq[fromIndex + i - 1] end\n\n        if fromIndex < toIndex then\n            -- Moving right: shift elements [from+stepSlots .. to+stepSlots-1] left by stepSlots\n            for i = fromIndex, toIndex - 1 do\n                seq[i] = seq[i + stepSlots]\n            end\n        else\n            -- Moving left: shift elements [to .. from-1] right by stepSlots\n            for i = fromIndex + stepSlots - 1, toIndex + stepSlots, -1 do\n                seq[i] = seq[i - stepSlots]\n            end\n        end\n\n        -- Place the block at toIndex\n        for i = 1, stepSlots do seq[toIndex + i - 1] = block[i] end\n    else\n        -- Single slot: original shift logic\n        if fromIndex < toIndex then\n            for i = fromIndex, toIndex - 1 do seq[i] = seq[i + 1] end\n        else\n            for i = fromIndex, toIndex + 1, -1 do seq[i] = seq[i - 1] end\n        end\n        seq[toIndex] = movingStep\n    end\n\n    if seqIndex == state.activeSequenceIndex then\n        state.sequence = state.sequences[seqIndex]\n        state.sequenceLength = state.sequenceLengths[seqIndex] or 0\n    end\n\n    return true\nend\n\n-- Move lyric text from one step to another without moving note data.\n-- If destination already has lyric text, lyrics are swapped.\nfunction core.moveStepLyric(state, seqIndex, fromIndex, toIndex)\n    if not seqIndex or not state.sequences[seqIndex] then\n        return false\n    end\n\n    local seq = state.sequences[seqIndex]\n    local length = state.sequenceLengths[seqIndex] or 0\n    if fromIndex < 1 or fromIndex > length or toIndex < 1 or toIndex > length then\n        return false\n    end\n    if fromIndex == toIndex then\n        return false\n    end\n\n    local fromStep = seq[fromIndex]\n    local toStep = seq[toIndex]\n    if not fromStep or not toStep then\n        return false\n    end\n\n    local fromLyric = fromStep.lyric\n    local toLyric = toStep.lyric\n    fromStep.lyric = toLyric\n    toStep.lyric = fromLyric\n    return true\nend\n\n-- Set a step to a rest (note index 13, no frequency)\nfunction core.setStepRest(state, seqIndex, stepIndex)\n    if not seqIndex or not state.sequences[seqIndex] then\n        return false\n    end\n    local length = state.sequenceLengths[seqIndex] or 0\n    -- sequenceLength may have been updated without syncing sequenceLengths; use the larger\n    if seqIndex == state.activeSequenceIndex then\n        length = math.max(length, state.sequenceLength or 0)\n    end\n    if stepIndex < 1 or stepIndex > length then\n        return false\n    end\n    local existingRest = state.sequences[seqIndex][stepIndex]\n    state.sequences[seqIndex][stepIndex] = {\n        note   = 13,\n        octave = 4,\n        lyric  = existingRest and existingRest.lyric or nil,\n        length = existingRest and existingRest.length or nil,\n        gate   = existingRest and existingRest.gate or nil,\n    }\n    -- Sync active sequence if needed\n    if seqIndex == state.activeSequenceIndex then\n        state.sequence = state.sequences[seqIndex]\n    end\n    return true\nend\n\n-- Get the length (in steps) of a step. Returns 1 if not set.\nfunction core.getStepLength(stepData)\n    if not stepData then return 1 end\n    return stepData.length or 1\nend\n\nlocal function gcd(a, b)\n    while b ~= 0 do\n        a, b = b, a % b\n    end\n    return a\nend\n\n-- Format a step length as a musical note value string (e.g. 0.25 → \"1/4\", 1/3 → \"1/3\", 2/5 → \"2/5\").\nfunction core.formatNoteLength(len)\n    if len > 128 then\n        return string.format(\"x%.3g\", len)\n    end\n    -- Use 3360ths (LCM of 32, 3, 5, and 7) to represent standard, triplet, quintuplet, and septuplet values exactly.\n    local units = math.max(1, math.floor(len * 3360 + 0.5))\n    local whole = math.floor(units / 3360)\n    local remainder = units % 3360\n    if remainder == 0 then\n        return tostring(whole)\n    end\n    local divisor = gcd(remainder, 3360)\n    local numerator = remainder / divisor\n    local denominator = 3360 / divisor\n    local fraction = string.format(\"%d/%d\", numerator, denominator)\n    if whole == 0 then\n        return fraction\n    end\n    return string.format(\"%d %s\", whole, fraction)\nend\n\n-- Musical note length snap values (in step units).\n-- Includes sub-steps (1/32, 1/16), every 1/8-step to 128, triplets (1/3), quintuplets (1/5), and septuplets (1/7) to 128.\ndo\n    local set = {}\n    for _, v in ipairs({1/32, 1/16}) do set[v] = true end\n    for eighth = 1, 1024 do set[eighth / 8]  = true end  -- 1/8 to 128 in 1/8 increments\n    for n = 1, 384  do set[n / 3] = true end              -- 1/3 to 128 in 1/3 increments (triplets)\n    for n = 1, 640  do set[n / 5] = true end              -- 1/5 to 128 in 1/5 increments (quintuplets)\n    for n = 1, 896  do set[n / 7] = true end              -- 1/7 to 128 in 1/7 increments (septuplets)\n    core.NOTE_LENGTHS = {}\n    for v in pairs(set) do core.NOTE_LENGTHS[#core.NOTE_LENGTHS + 1] = v end\n    table.sort(core.NOTE_LENGTHS)\nend\n\n-- Precompute key-hold range indices: [0.25, 8.0].\n-- Linear index mapping over this range gives equal dwell time per slot (~38ms at 3000ms max).\n-- Values outside this range are better reached via drag or L1/R1 stretch.\ncore._keyHoldStartIdx = 1\ncore._keyHoldEndIdx   = #core.NOTE_LENGTHS\nfor i, v in ipairs(core.NOTE_LENGTHS) do\n    if v >= 0.25 - 0.001 then core._keyHoldStartIdx = i; break end\nend\nfor i = #core.NOTE_LENGTHS, 1, -1 do\n    if core.NOTE_LENGTHS[i] <= 8.0 + 0.001 then core._keyHoldEndIdx = i; break end\nend\n\n-- Snap a raw length to the nearest musical note value.\n-- Values <= 64 snap to the NOTE_LENGTHS table (binary search); larger values round to nearest integer.\nfunction core.snapToNoteLength(len)\n    if len > 128 then\n        return math.floor(len + 0.5)\n    end\n    local t = core.NOTE_LENGTHS\n    local lo, hi = 1, #t\n    while lo < hi do\n        local mid = math.floor((lo + hi) / 2)\n        if t[mid] < len then lo = mid + 1 else hi = mid end\n    end\n    if lo > #t then return t[#t] end\n    if lo == 1 then return t[1] end\n    return math.abs(t[lo] - len) <= math.abs(t[lo - 1] - len) and t[lo] or t[lo - 1]\nend\n\n-- Step to the next longer note value above currentLen.\nfunction core.nextNoteLength(currentLen)\n    for _, v in ipairs(core.NOTE_LENGTHS) do\n        if v > currentLen + 0.001 then return v end\n    end\n    -- For values > 128, step up by 1\n    return math.floor(currentLen + 0.5) + 1\nend\n\n-- Step to the next shorter note value below currentLen.\nfunction core.prevNoteLength(currentLen)\n    for i = #core.NOTE_LENGTHS, 1, -1 do\n        if core.NOTE_LENGTHS[i] < currentLen - 0.001 then return core.NOTE_LENGTHS[i] end\n    end\n    -- Already at minimum\n    return core.NOTE_LENGTHS[1]\nend\n\n-- Stretch a step to a new length, shifting subsequent steps right or left.\n-- stepIndex: 1-based index in state.sequence\n-- newLength: desired length (0.125–max possible); snaps to musical note values.\n--   Lengths < 1 affect only playback timing (step still occupies 1 slot).\n--   Lengths > 1 insert nil continuation slots (ceil(length)-1 slots).\n-- Returns true if the length changed.\nfunction core.stretchStep(state, stepIndex, newLength, skipSnap)\n    local step = state.sequence[stepIndex]\n    if not step then return false end\n    local maxLength = math.max(1, core.maxSteps - stepIndex + 1)\n    if not skipSnap then\n        newLength = core.snapToNoteLength(newLength)\n    end\n    newLength = math.max(core.NOTE_LENGTHS[1], math.min(maxLength, newLength))\n    local oldLength = step.length or 1\n    if newLength == oldLength then return false end\n\n    local seq = state.sequence\n    local seqLen = state.sequenceLength\n\n    -- Nil continuation slots needed = ceil(length) - 1\n    local oldSlots = math.ceil(oldLength) - 1\n    local newSlots = math.ceil(newLength) - 1\n    local slotDelta = newSlots - oldSlots\n\n    if slotDelta > 0 then\n        -- Growing: insert slotDelta nil slots after the step's current continuation range\n        local insertAt = stepIndex + oldSlots + 1\n        local newSeqLen = math.min(core.maxSteps, seqLen + slotDelta)\n        for i = newSeqLen, insertAt + slotDelta, -1 do\n            seq[i] = seq[i - slotDelta]\n        end\n        for i = insertAt, insertAt + slotDelta - 1 do\n            seq[i] = nil\n        end\n        state.sequenceLength = newSeqLen\n    elseif slotDelta < 0 then\n        -- Shrinking: remove |slotDelta| continuation slots\n        local removeAt = stepIndex + newSlots + 1\n        local removeCount = -slotDelta\n        for i = removeAt, seqLen - removeCount do\n            seq[i] = seq[i + removeCount]\n        end\n        for i = seqLen - removeCount + 1, seqLen do\n            seq[i] = nil\n        end\n        state.sequenceLength = math.max(stepIndex, seqLen - removeCount)\n    end\n    -- (slotDelta == 0: length changed within same slot count, e.g. 1.0↔0.5 or 1.0↔1.5)\n\n    step.length = newLength\n    -- Keep sequenceLengths in sync so playback and history capture see the new length\n    if state.activeSequenceIndex then\n        state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n    end\n    return true\nend\n\nreturn core\n","storage.lua":"local storage = {}\n\nfunction storage.savePreferences(writeData, filename, state)\n    local preferences = {\n        defaultTempo = state.defaultTempo,\n        stepBeats = state.stepBeats,\n        meterNumerator = state.meterNumerator,\n        meterDenominator = state.meterDenominator,\n        showHandsDuringPlayback = state.showHandsDuringPlayback,\n        soundPreviewEnabled = state.soundPreviewEnabled,\n        acapellaMode = state.acapellaMode,\n        singSolfegeMode = state.singSolfegeMode,\n        defaultSingSolfegeMode = state.defaultSingSolfegeMode,\n        darkMode = state.darkMode,\n        pitchRecognitionEnabled = state.pitchRecognitionEnabled,\n        playKeyBeforeSteps = state.playKeyBeforeSteps,\n        keyLeadInBeats = state.keyLeadInBeats,\n        rootNote = state.rootNote,\n        keyShift = state.keyShift,\n        solfegeScale = state.solfegeScale,\n        keyNote = state.keyNote,\n        keyOctave = state.keyOctave,\n        randomizeRootPlayback = state.randomizeRootPlayback,\n        randomizeOctavePlayback = state.randomizeOctavePlayback,\n        hideNoteNamesDuringPlayback = state.hideNoteNamesDuringPlayback,\n        earTrainingRevealAfterPlayback = state.earTrainingRevealAfterPlayback == true,\n        showNoteNames = state.showNoteNames,\n        showRomanNumerals = state.showRomanNumerals,\n        useShapeNotes = state.useShapeNotes,\n        showNoteLengths = state.showNoteLengths,\n        showOctaveNumbers = state.showOctaveNumbers,\n        showLyrics = state.showLyrics,\n        showSolfegeLyrics = state.showSolfegeLyrics,\n        hideSteps = state.hideSteps,\n        showAddStepButton = state.showAddStepButton,\n        showSolfegeTextInput = state.showSolfegeTextInput,\n        showSolfegeButtons = state.showSolfegeButtons,\n        solfegeTextInputSide = state.solfegeTextInputSide,\n        showToolsRow = state.showToolsRow,\n        showBarsBeatsRow = state.showBarsBeatsRow,\n        showMicRow = state.showMicRow,\n        playbackStopSeconds = state.playbackStopSeconds,\n        droneEnabled = state.droneEnabled == true,\n        droneNoteSelection = state.droneNoteSelection,\n        droneOctave = state.droneOctave,\n        selectedPatternType = state.selectedPatternType,\n        selectedPatternByType = state.selectedPatternByType,\n        selectedPattern = state.selectedPattern,\n        patternTargetSequenceIndex = state.patternTargetSequenceIndex,\n        patternOctave = state.patternOctave,\n        sidebarOpen = state.sidebarOpen,\n        midiInDeviceName = state.midiInDeviceName,\n        micInputDeviceName = state.micInputDeviceName,\n        midiMappings = state.midiMappings,\n        midiOutEnabled = state.midiOutEnabled,\n        masterVolume = state.masterVolume,\n        streamAudioEnabled = state.streamAudioEnabled == true,\n        gamepadEnabled = state.gamepadEnabled,\n        gamepadDeviceName = state.gamepadDeviceName,\n        rowBreakAfterStep = state.rowBreakAfterStep,\n        solfegeInputWidth = state.solfegeInputWidth,\n        solfegeTextMode = state.solfegeTextMode,\n        solfegeFloatX = state.solfegeFloatX,\n        solfegeFloatY = state.solfegeFloatY,\n        solfegeFloatW = state.solfegeFloatW,\n        solfegeFloatH = state.solfegeFloatH,\n        solfegeTextFontSize = state.solfegeTextFontSize,\n        solfegeBottomH = state.solfegeBottomH,\n        lnSplitRatio = state.lnSplitRatio,\n        solfegeShowBreaks = state.solfegeShowBreaks,\n        solfegeSpellCheck = state.solfegeSpellCheck,\n        solfegeTextOnlyMode = state.solfegeTextOnlyMode,\n        cmdChatMuted = state.cmdChatMuted,\n        customScaleIntervals = state.customScaleIntervals,\n        userSolfegeTemplates = state.userSolfegeTemplates or {},\n    }\n\n    writeData(filename, preferences)\nend\n\nfunction storage.loadPreferences(readData, filename, state)\n    local data = readData(filename)\n\n    if type(data) ~= \"table\" then\n        return false\n    end\n\n    if type(data.defaultTempo) == \"number\" then\n        state.defaultTempo = math.min(990, math.max(1, data.defaultTempo))\n    end\n    if type(data.stepBeats) == \"number\" and data.stepBeats > 0 then\n        state.stepBeats = data.stepBeats\n    end\n    if type(data.meterNumerator) == \"number\" and type(data.meterDenominator) == \"number\" then\n        state.meterNumerator = math.max(1, math.floor(data.meterNumerator))\n        state.meterDenominator = math.max(1, math.floor(data.meterDenominator))\n    end\n    -- Skip loading these preferences - always use defaults\n    -- if data.showHandsDuringPlayback ~= nil then\n    --     state.showHandsDuringPlayback = data.showHandsDuringPlayback\n    -- end\n    -- if data.soundPreviewEnabled ~= nil then\n    --     state.soundPreviewEnabled = data.soundPreviewEnabled\n    -- end\n    if data.acapellaMode ~= nil then\n        state.acapellaMode = data.acapellaMode\n    end\n    if data.singSolfegeMode ~= nil then\n        state.singSolfegeMode = data.singSolfegeMode\n    end\n    if data.defaultSingSolfegeMode ~= nil then\n        state.defaultSingSolfegeMode = data.defaultSingSolfegeMode\n    end\n    if data.pitchRecognitionEnabled ~= nil then\n        state.pitchRecognitionEnabled = data.pitchRecognitionEnabled\n    end\n    -- if data.playKeyBeforeSteps ~= nil then\n    --     state.playKeyBeforeSteps = data.playKeyBeforeSteps\n    -- end\n    if data.darkMode ~= nil then\n        state.darkMode = data.darkMode\n    end\n    if data.keyLeadInBeats ~= nil then\n        state.keyLeadInBeats = data.keyLeadInBeats\n    end\n    if data.rootNote ~= nil then\n        state.rootNote = math.min(math.max(0, data.rootNote), 12)\n    end\n    if data.keyShift ~= nil then\n        state.keyShift = ((data.keyShift % 12) + 12) % 12\n    end\n    if data.solfegeScale == \"minor\" then\n        -- Backward compatibility with older preferences.\n        state.solfegeScale = \"natural_minor\"\n    elseif data.solfegeScale == \"natural_minor\"\n        or data.solfegeScale == \"harmonic_minor\"\n        or data.solfegeScale == \"melodic_minor\"\n        or data.solfegeScale == \"all\"\n        or data.solfegeScale == \"custom\" then\n        state.solfegeScale = data.solfegeScale\n    else\n        state.solfegeScale = \"major\"\n    end\n    if type(data.customScaleIntervals) == \"string\" then\n        state.customScaleIntervals = data.customScaleIntervals\n    end\n    if data.keyNote ~= nil then\n        state.keyNote = math.min(math.max(0, data.keyNote), 11)\n    end\n    if data.keyOctave ~= nil then\n        state.keyOctave = data.keyOctave\n    end\n    if data.hideNoteNamesDuringPlayback ~= nil then\n        state.hideNoteNamesDuringPlayback = data.hideNoteNamesDuringPlayback\n    end\n    if data.earTrainingRevealAfterPlayback ~= nil then\n        state.earTrainingRevealAfterPlayback = data.earTrainingRevealAfterPlayback == true\n    end\n    if data.showNoteNames ~= nil then\n        state.showNoteNames = data.showNoteNames\n    end\n    if data.showNoteLengths ~= nil then\n        state.showNoteLengths = data.showNoteLengths\n    end\n    if data.showOctaveNumbers ~= nil then\n        state.showOctaveNumbers = data.showOctaveNumbers\n    end\n    if data.showRomanNumerals ~= nil then\n        state.showRomanNumerals = data.showRomanNumerals\n    end\n    if data.useShapeNotes ~= nil then\n        state.useShapeNotes = data.useShapeNotes\n    end\n    if data.showLyrics ~= nil then\n        state.showLyrics = data.showLyrics\n    end\n    if data.showSolfegeLyrics ~= nil then\n        state.showSolfegeLyrics = data.showSolfegeLyrics\n    end\n    if data.hideSteps ~= nil then\n        state.hideSteps = data.hideSteps\n    end\n    if data.showAddStepButton ~= nil then\n        state.showAddStepButton = data.showAddStepButton\n    end\n    if data.showSolfegeTextInput ~= nil then\n        state.showSolfegeTextInput = data.showSolfegeTextInput\n    end\n    if data.showSolfegeButtons ~= nil then\n        state.showSolfegeButtons = data.showSolfegeButtons\n    end\n    if data.solfegeTextInputSide ~= nil then\n        state.solfegeTextInputSide = data.solfegeTextInputSide\n    end\n    if data.showToolsRow ~= nil then\n        state.showToolsRow = data.showToolsRow\n    end\n    if data.showBarsBeatsRow ~= nil then\n        state.showBarsBeatsRow = data.showBarsBeatsRow\n    end\n    if data.showMicRow ~= nil then\n        state.showMicRow = data.showMicRow\n    end\n    if data.cmdChatMuted ~= nil then\n        state.cmdChatMuted = data.cmdChatMuted == true\n    end\n    -- Skip loading these preferences - always use defaults\n    -- if data.randomizeRootPlayback ~= nil then\n    --     state.randomizeRootPlayback = data.randomizeRootPlayback\n    -- end\n    -- if data.randomizeOctavePlayback ~= nil then\n    --     state.randomizeOctavePlayback = data.randomizeOctavePlayback\n    -- end\n    if data.muteStepSequence ~= nil then\n        state.sequenceMutes[state.activeSequenceIndex] = data.muteStepSequence\n    end\n    if data.playbackStopSeconds ~= nil then\n        state.playbackStopSeconds = data.playbackStopSeconds\n    end\n    if data.droneEnabled ~= nil then\n        state.droneEnabled = data.droneEnabled == true\n    end\n    if type(data.droneNoteSelection) == \"number\" then\n        state.droneNoteSelection = math.min(12, math.max(0, math.floor(data.droneNoteSelection)))\n    end\n    if type(data.droneOctave) == \"number\" then\n        state.droneOctave = math.min(7, math.max(2, math.floor(data.droneOctave)))\n    end\n    if data.selectedPatternType ~= nil then\n        local maxTypes = #state.selectedPatternByType\n        state.selectedPatternType = math.min(math.max(1, data.selectedPatternType), maxTypes)\n    end\n    if data.selectedPatternByType ~= nil and type(data.selectedPatternByType) == \"table\" then\n        for i = 1, #state.selectedPatternByType do\n            local savedValue = data.selectedPatternByType[i]\n            if type(savedValue) == \"number\" then\n                state.selectedPatternByType[i] = math.max(1, savedValue)\n            end\n        end\n    end\n    if data.selectedPattern ~= nil then\n        state.selectedPattern = math.max(1, data.selectedPattern)\n    end\n    if data.patternTargetSequenceIndex ~= nil then\n        state.patternTargetSequenceIndex = math.max(1, data.patternTargetSequenceIndex)\n    end\n    if data.patternOctave ~= nil then\n        state.patternOctave = math.min(math.max(2, data.patternOctave), 7)\n    end\n    if data.sidebarOpen ~= nil then\n        state.sidebarOpen = data.sidebarOpen\n    end\n    if type(data.midiInDeviceName) == \"string\" then\n        state.midiInDeviceName = data.midiInDeviceName\n    end\n    if type(data.micInputDeviceName) == \"string\" then\n        state.micInputDeviceName = data.micInputDeviceName\n    end\n    if type(data.midiMappings) == \"table\" then\n        for k, v in pairs(data.midiMappings) do\n            if type(k) == \"string\" and type(v) == \"table\"\n               and type(v.type) == \"string\" and type(v.number) == \"number\" then\n                state.midiMappings[k] = { type = v.type, number = v.number }\n            end\n        end\n    end\n    if data.midiOutEnabled ~= nil then\n        state.midiOutEnabled = data.midiOutEnabled\n    end\n    if type(data.masterVolume) == \"number\" then\n        state.masterVolume = math.max(0, math.min(1, data.masterVolume))\n    end\n    if data.streamAudioEnabled ~= nil then\n        state.streamAudioEnabled = data.streamAudioEnabled == true\n    end\n    if data.gamepadEnabled ~= nil then\n        state.gamepadEnabled = data.gamepadEnabled\n    end\n    if type(data.gamepadDeviceName) == \"string\" then\n        state.gamepadDeviceName = data.gamepadDeviceName\n    end\n    if type(data.rowBreakAfterStep) == \"number\" then\n        local clamped = math.max(1, math.min(63, math.floor(data.rowBreakAfterStep)))\n        state.rowBreakAfterStep = clamped\n    elseif type(data.rowBreakAfterStep) == \"table\" then\n        local breaks = {}\n        for _, v in ipairs(data.rowBreakAfterStep) do\n            if type(v) == \"number\" and v >= 1 then breaks[#breaks + 1] = math.floor(v) end\n        end\n        state.rowBreakAfterStep = (#breaks > 0) and breaks or nil\n    elseif data.rowBreakAfterStep == false then\n        state.rowBreakAfterStep = nil\n    end\n    if type(data.solfegeInputWidth) == \"number\" then\n        state.solfegeInputWidth = math.max(80, math.min(260, math.floor(data.solfegeInputWidth)))\n    end\n    if data.solfegeTextMode == \"steps\" or data.solfegeTextMode == \"lyrics\" or data.solfegeTextMode == \"both\" then\n        state.solfegeTextMode = data.solfegeTextMode\n    end\n    if type(data.solfegeFloatX) == \"number\" then state.solfegeFloatX = data.solfegeFloatX end\n    if type(data.solfegeFloatY) == \"number\" then state.solfegeFloatY = data.solfegeFloatY end\n    if type(data.solfegeFloatW) == \"number\" then state.solfegeFloatW = math.max(140, math.min(400, data.solfegeFloatW)) end\n    if type(data.solfegeFloatH) == \"number\" then state.solfegeFloatH = math.max(80, data.solfegeFloatH) end\n    if type(data.solfegeTextFontSize) == \"number\" then\n        local fontSize = math.floor(data.solfegeTextFontSize)\n        if fontSize >= 1 and fontSize <= 5 then\n            state.solfegeTextFontSize = fontSize\n        end\n    end\n    if type(data.solfegeBottomH) == \"number\" and data.solfegeBottomH >= 40 then\n        state.solfegeBottomH = data.solfegeBottomH\n    end\n    if type(data.lnSplitRatio) == \"number\" and data.lnSplitRatio >= 0.2 and data.lnSplitRatio <= 0.65 then\n        state.lnSplitRatio = data.lnSplitRatio\n    end\n    if type(data.solfegeShowBreaks) == \"boolean\" then\n        state.solfegeShowBreaks = data.solfegeShowBreaks\n    end\n    if type(data.solfegeSpellCheck) == \"boolean\" then\n        state.solfegeSpellCheck = data.solfegeSpellCheck\n    end\n    if type(data.solfegeTextOnlyMode) == \"boolean\" then\n        state.solfegeTextOnlyMode = data.solfegeTextOnlyMode\n    end\n    if type(data.userSolfegeTemplates) == \"table\" then\n        local out = {}\n        for _, t in ipairs(data.userSolfegeTemplates) do\n            if type(t.name) == \"string\" and type(t.text) == \"string\" then\n                out[#out + 1] = {name = t.name, text = t.text}\n            end\n        end\n        state.userSolfegeTemplates = out\n    end\n    return true\nend\n\nfunction storage.saveSequence(writeData, filename, state)\n    local saveData = {\n        sequences = state.sequences,\n        sequenceLengths = state.sequenceLengths,\n        sequenceOctaveTranspose = state.sequenceOctaveTranspose,\n        activeSequenceIndex = state.activeSequenceIndex,\n        sequence = state.sequence,\n        sequenceLength = state.sequenceLength,\n        sequenceMutes = state.sequenceMutes,\n        tempo = state.tempo,\n        stepBeats = state.stepBeats,\n        meterNumerator = state.meterNumerator,\n        meterDenominator = state.meterDenominator,\n        currentOctave = state.currentOctave,\n        selectedNote = state.selectedNote,\n        lyricNotesBuffer = state.lyricNotesBuffer,\n        lyricNotesCursor = state.lyricNotesCursor\n    }\n\n    writeData(filename, saveData)\nend\n\nfunction storage.loadSequence(readData, filename, state, core, setTempo)\n    local data = readData(filename)\n\n    if type(data) ~= \"table\" then\n        return false\n    end\n\n    state.sequences = {}\n    state.sequenceLengths = {}\n    state.sequenceOctaveTranspose = {}\n    if data.sequences then\n        state.sequences = data.sequences\n        state.sequenceLengths = data.sequenceLengths or {}\n        state.sequenceOctaveTranspose = data.sequenceOctaveTranspose or {}\n        state.activeSequenceIndex = data.activeSequenceIndex or 1\n        state.sequenceMutes = data.sequenceMutes or state.sequenceMutes or {}\n    else\n        state.sequences[1] = data.sequence or {}\n        state.sequenceLengths[1] = data.sequenceLength or 0\n        state.sequenceOctaveTranspose[1] = 0\n        state.activeSequenceIndex = 1\n        state.sequenceMutes = state.sequenceMutes or {false}\n    end\n    state.activeSequenceIndex = math.min(math.max(1, state.activeSequenceIndex), core.maxSequences)\n    for i = 1, core.maxSequences do\n        if state.sequences[i] and state.sequenceLengths[i] == nil then\n            local length = 0\n            for stepIndex = 1, core.maxSteps do\n                if state.sequences[i][stepIndex] ~= nil then\n                    length = stepIndex\n                end\n            end\n            state.sequenceLengths[i] = length\n        end\n        if state.sequences[i] and state.sequenceMutes[i] == nil then\n            state.sequenceMutes[i] = false\n        end\n        if state.sequences[i] and state.sequenceOctaveTranspose[i] == nil then\n            state.sequenceOctaveTranspose[i] = 0\n        end\n    end\n    if not state.sequences[1] then\n        state.sequences[1] = {}\n    end\n    if not state.sequenceLengths[1] then\n        state.sequenceLengths[1] = 0\n    end\n    if not state.sequences[state.activeSequenceIndex] then\n        state.activeSequenceIndex = 1\n    end\n    state.sequence = state.sequences[state.activeSequenceIndex]\n    state.sequenceLength = state.sequenceLengths[state.activeSequenceIndex] or 0\n    if type(data.stepBeats) == \"number\" and data.stepBeats > 0 then\n        state.stepBeats = data.stepBeats\n    end\n    if type(data.meterNumerator) == \"number\" and type(data.meterDenominator) == \"number\" then\n        core.setTimeSignature(state, data.meterNumerator, data.meterDenominator)\n    end\n    local tempo = data.tempo\n    if type(tempo) ~= \"number\" then\n        tempo = state.defaultTempo\n    end\n    setTempo(math.min(990, math.max(1, tempo)))\n    state.currentOctave = data.currentOctave or 4\n    state.selectedNote = data.selectedNote or 0\n    if type(data.lyricNotesBuffer) == \"string\" then\n        state.lyricNotesBuffer = data.lyricNotesBuffer\n        state.lyricNotesCursor = data.lyricNotesCursor\n    end\n\n    if state.sequenceLength == 0 then\n        for i = 1, core.maxSteps do\n            if state.sequence[i] ~= nil then\n                state.sequenceLength = i\n            end\n        end\n    end\n    state.sequenceLengths[state.activeSequenceIndex] = state.sequenceLength\n\n    return true\nend\n\n-- Template storage functions\nlocal function getTemplateFilename(templateId)\n    return \"template_\" .. tostring(templateId) .. \".musicxml\"\nend\n\nfunction storage.saveTemplate(writeData, template)\n    local filename = getTemplateFilename(template.id)\n    writeData(filename, template)\n    return true\nend\n\nfunction storage.loadTemplate(readData, templateId)\n    local filename = getTemplateFilename(templateId)\n    local data = readData(filename)\n    if data ~= nil then\n        return data\n    end\n\n    -- Backward compatibility with legacy template filenames.\n    local legacyFilename = \"template_\" .. tostring(templateId)\n    data = readData(legacyFilename)\n    return data\nend\n\nfunction storage.listUserTemplates(readData)\n    -- This will return a list of user template IDs\n    -- For now, return empty list - will be populated as users save templates\n    return {}\nend\n\nfunction storage.deleteTemplate(deleteData, templateId)\n    local filename = getTemplateFilename(templateId)\n    deleteData(filename)\n\n    -- Best effort cleanup for legacy template storage names.\n    local legacyFilename = \"template_\" .. tostring(templateId)\n    if legacyFilename ~= filename then\n        deleteData(legacyFilename)\n    end\n    return true\nend\n\nfunction storage.updateTemplateInCloud(updateCloudData, template)\n    if type(updateCloudData) ~= \"function\" then\n        return false\n    end\n    if not template or not template.id then\n        return false\n    end\n    local payload = {\n        id = template.id,\n        name = template.name,\n        description = template.description,\n        updatedAt = os.time(),\n        template = template\n    }\n    local result = updateCloudData(payload)\n    return result ~= false\nend\n\nreturn storage\n","storage_adapter.lua":"local StorageAdapter = {}\n\nfunction StorageAdapter.new(implementation)\n    local self = {}\n\n    function self.read(filename)\n        return implementation.read(filename)\n    end\n\n    function self.write(filename, data)\n        return implementation.write(filename, data)\n    end\n\n    function self.delete(filename)\n        if implementation.delete then\n            return implementation.delete(filename)\n        end\n        return false\n    end\n\n    function self.list(prefix)\n        if implementation.list then\n            return implementation.list(prefix)\n        end\n        return {}\n    end\n\n    function self.readBinary(filename)\n        if implementation.readBinary then\n            return implementation.readBinary(filename)\n        end\n        local file = io.open(filename, \"rb\")\n        if not file then\n            return nil, \"Could not open file: \" .. tostring(filename)\n        end\n        local data = file:read(\"*a\")\n        file:close()\n        return data\n    end\n\n    function self.updateTemplateInCloud(templatePayload)\n        if implementation.updateTemplateInCloud then\n            return implementation.updateTemplateInCloud(templatePayload)\n        end\n        return false\n    end\n\n    function self.getStorageRoot()\n        if implementation.getStorageRoot then\n            return implementation.getStorageRoot()\n        end\n        return nil\n    end\n\n    return self\nend\n\nreturn StorageAdapter\n","system_adapter.lua":"-- system_adapter.lua\n-- Platform-agnostic system services adapter\n\nlocal SystemAdapter = {}\nSystemAdapter.__index = SystemAdapter\n\nfunction SystemAdapter.new(impl)\n    local self = setmetatable({}, SystemAdapter)\n    self.impl = impl or {}\n    return self\nend\n\nfunction SystemAdapter:getCurrentTimeMilliseconds()\n    if self.impl.getCurrentTimeMilliseconds then\n        return self.impl.getCurrentTimeMilliseconds()\n    end\n    return math.floor(os.clock() * 1000)\nend\n\nfunction SystemAdapter:setAutoLockDisabled(enabled)\n    if self.impl.setAutoLockDisabled then\n        self.impl.setAutoLockDisabled(enabled)\n    end\nend\n\nfunction SystemAdapter:isUsbConnected()\n    if self.impl.isUsbConnected then\n        return self.impl.isUsbConnected()\n    end\n    return false\nend\n\nfunction SystemAdapter:setupMenu(menuItems)\n    if self.impl.setupMenu then\n        self.impl.setupMenu(menuItems)\n    end\nend\n\nfunction SystemAdapter:registerLifecycleHandlers(handlers)\n    if self.impl.registerLifecycleHandlers then\n        self.impl.registerLifecycleHandlers(handlers)\n    end\nend\n\nreturn SystemAdapter\n","template_library.lua":"local templateLibrary = {}\n\nlocal function resolveModule(name)\n    if type(rawget(_G, \"import\")) == \"function\" then\n        return import(name)\n    end\n    return require(name)\nend\n\nlocal TEMPLATE_SOLFEGE_NOTE = {\n    [\"do\"]=0, [\"di\"]=1, [\"ra\"]=1, [\"re\"]=2, [\"ri\"]=3, [\"me\"]=3,\n    [\"mi\"]=4, [\"fa\"]=5, [\"fi\"]=6, [\"se\"]=6, [\"sol\"]=7, [\"si\"]=8,\n    [\"le\"]=8, [\"la\"]=9, [\"li\"]=10, [\"te\"]=10, [\"ti\"]=11, [\"do'\"]=12,\n    [\"rest\"]=13, [\"r\"]=13, [\"--\"]=13,\n}\nlocal TEMPLATE_DURATION_MAP = {\n    [\"1\"]=4, [\"2\"]=2, [\"4\"]=1, [\"8\"]=0.5, [\"16\"]=0.25, [\"32\"]=0.125,\n    [\"8t\"]=1/3, [\"4t\"]=2/3, [\"16t\"]=1/6, [\"2t\"]=4/3,\n    [\"d8\"]=0.75, [\"d4\"]=1.5, [\"d2\"]=3, [\"d1\"]=6,\n}\nlocal function parseTemplateTextToken(token)\n    local t = token:match(\"^%s*(.-)%s*$\")\n    if not t or t == \"\" then return nil end\n    local core_part, lyric = t:match(\"^(.-)%|(.+)$\")\n    if not core_part then core_part = t; lyric = nil end\n    local restDur = core_part:match(\"^%-%-/?(.*)$\")\n    if restDur ~= nil then\n        return {note=13, octave=nil, length=(restDur~=\"\" and TEMPLATE_DURATION_MAP[restDur:lower()]) or nil, lyric=lyric}\n    end\n    local syllable, octStr, durStr = core_part:match(\"^([A-Za-z][A-Za-z'%-]*)(%d?)/?(.*)$\")\n    if not syllable then return nil end\n    local noteIdx = TEMPLATE_SOLFEGE_NOTE[syllable:lower()]\n    if noteIdx == nil then return nil end\n    return {\n        note   = noteIdx,\n        octave = (octStr~=\"\" and tonumber(octStr)) or nil,\n        length = (durStr~=\"\" and TEMPLATE_DURATION_MAP[durStr:lower()]) or nil,\n        lyric  = lyric,\n    }\nend\nlocal function parseTemplateText(text, defaultOctave)\n    defaultOctave = defaultOctave or 4\n    local steps = {}\n    for token in text:gmatch(\"%S+\") do\n        local parsed = parseTemplateTextToken(token)\n        if parsed then\n            steps[#steps+1] = {note=parsed.note, octave=parsed.octave or defaultOctave,\n                               length=parsed.length, lyric=parsed.lyric}\n        end\n    end\n    return steps\nend\n\n-- Template categories organized by musical concepts\ntemplateLibrary.categories = {\n    -- Scale Categories\n    \"From Do\",\n    \"Major Scales\",\n    \"Natural Minor\",\n    \"Pentatonic Scales\",\n    \"Modes & Advanced\",\n    -- Exercise Categories\n    \"Major Exercise Patterns\",\n    \"Major Exercise Intervals & Harmony\",\n    \"Range Exercises\",\n    -- Ear Training Categories\n    \"Melody Ear Training\",\n    \"Harmony Ear Training\",\n    \"Rhythm Ear Training\",\n    \"Functional Ear Training\",\n    \"A Cappella Ear Training\",\n    -- Harmony Categories\n    \"Arpeggios\",\n    \"Chords\",\n    -- Composition Categories\n    \"Song Forms\",\n    \"Jazz & Blues\",\n    \"User\"\n}\n\n-- Built-in templates organized by category\ntemplateLibrary.builtInTemplates = {\n    -- INTERVAL TRAINING TEMPLATES\n    intervals = {\n        {\n            id = \"intervals_major_scale_from_do\",\n            name = \"Major Scale Intervals from Do\",\n            description = \"Major-scale intervals measured from Do to each scale degree\",\n            difficulty = 1,\n            tags = {\"intervals\", \"scale\", \"major\", \"do\", \"tonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 0, octave = 4}},   -- Do-Do (unison)\n                [2] = {{note = 0, octave = 4}, {note = 2, octave = 4}},   -- Do-Re (M2)\n                [3] = {{note = 0, octave = 4}, {note = 4, octave = 4}},   -- Do-Mi (M3)\n                [4] = {{note = 0, octave = 4}, {note = 5, octave = 4}},   -- Do-Fa (P4)\n                [5] = {{note = 0, octave = 4}, {note = 7, octave = 4}},   -- Do-Sol (P5)\n                [6] = {{note = 0, octave = 4}, {note = 9, octave = 4}},   -- Do-La (M6)\n                [7] = {{note = 0, octave = 4}, {note = 11, octave = 4}},  -- Do-Ti (M7)\n                [8] = {{note = 0, octave = 4}, {note = 12, octave = 4}}   -- Do-Do' (octave)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_perfect_intervals\",\n            name = \"Perfect Intervals\",\n            description = \"Unison, P4, P5, and octave interval training\",\n            difficulty = 1,\n            tags = {\"intervals\", \"perfect\", \"harmonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 0, octave = 4}},  -- Unison\n                [2] = {{note = 0, octave = 4}, {note = 5, octave = 4}},  -- Perfect 4th\n                [3] = {{note = 0, octave = 4}, {note = 7, octave = 4}},  -- Perfect 5th\n                [4] = {{note = 0, octave = 4}, {note = 12, octave = 4}}  -- Octave\n            },\n            sequenceLengths = {2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_major_intervals\",\n            name = \"Major Intervals\",\n            description = \"M2, M3, M6, M7 interval recognition\",\n            difficulty = 2,\n            tags = {\"intervals\", \"major\", \"harmonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 2, octave = 4}},  -- Major 2nd\n                [2] = {{note = 0, octave = 4}, {note = 4, octave = 4}},  -- Major 3rd\n                [3] = {{note = 0, octave = 4}, {note = 9, octave = 4}},  -- Major 6th\n                [4] = {{note = 0, octave = 4}, {note = 11, octave = 4}}  -- Major 7th\n            },\n            sequenceLengths = {2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_minor_intervals\",\n            name = \"Minor Intervals\",\n            description = \"m2, m3, m6, m7 interval recognition\",\n            difficulty = 2,\n            tags = {\"intervals\", \"minor\", \"harmonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 1, octave = 4}},  -- Minor 2nd\n                [2] = {{note = 0, octave = 4}, {note = 3, octave = 4}},  -- Minor 3rd\n                [3] = {{note = 0, octave = 4}, {note = 8, octave = 4}},  -- Minor 6th\n                [4] = {{note = 0, octave = 4}, {note = 10, octave = 4}}  -- Minor 7th\n            },\n            sequenceLengths = {2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_tritone_practice\",\n            name = \"Tritone Training\",\n            description = \"Diminished 5th / Augmented 4th recognition\",\n            difficulty = 3,\n            tags = {\"intervals\", \"tritone\", \"dissonance\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 6, octave = 4}},  -- Tritone\n                [2] = {{note = 5, octave = 4}, {note = 11, octave = 4}}, -- Tritone from Fa\n                [3] = {{note = 7, octave = 4}, {note = 1, octave = 5}},  -- Tritone from Sol\n                [4] = {{note = 2, octave = 4}, {note = 8, octave = 4}}   -- Tritone from Re\n            },\n            sequenceLengths = {2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 80,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_all_chromatic_intervals\",\n            name = \"All Chromatic Intervals\",\n            description = \"Complete set of all 12 chromatic intervals\",\n            difficulty = 3,\n            tags = {\"intervals\", \"chromatic\", \"comprehensive\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 1, octave = 4}},   -- m2\n                [2] = {{note = 0, octave = 4}, {note = 2, octave = 4}},   -- M2\n                [3] = {{note = 0, octave = 4}, {note = 3, octave = 4}},   -- m3\n                [4] = {{note = 0, octave = 4}, {note = 4, octave = 4}},   -- M3\n                [5] = {{note = 0, octave = 4}, {note = 5, octave = 4}},   -- P4\n                [6] = {{note = 0, octave = 4}, {note = 6, octave = 4}},   -- Tritone\n                [7] = {{note = 0, octave = 4}, {note = 7, octave = 4}},   -- P5\n                [8] = {{note = 0, octave = 4}, {note = 8, octave = 4}}    -- m6\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 85,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = true\n            },\n            sequenceStates = {\n                mutes = {true, true, true, true, true, true, true, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_major_scale_2nds\",\n            name = \"Major Scale 2nds\",\n            description = \"All 2nd intervals within major scale (M2 and m2)\",\n            difficulty = 2,\n            tags = {\"intervals\", \"scale\", \"2nds\", \"diatonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 2, octave = 4}},   -- Do-Re (M2)\n                [2] = {{note = 2, octave = 4}, {note = 4, octave = 4}},   -- Re-Mi (M2)\n                [3] = {{note = 4, octave = 4}, {note = 5, octave = 4}},   -- Mi-Fa (m2)\n                [4] = {{note = 5, octave = 4}, {note = 7, octave = 4}},   -- Fa-Sol (M2)\n                [5] = {{note = 7, octave = 4}, {note = 9, octave = 4}},   -- Sol-La (M2)\n                [6] = {{note = 9, octave = 4}, {note = 11, octave = 4}},  -- La-Ti (M2)\n                [7] = {{note = 11, octave = 4}, {note = 12, octave = 4}}, -- Ti-Do (m2)\n                [8] = {{note = 0, octave = 4}, {note = 2, octave = 4}}    -- Do-Re (repeat)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 95,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_major_scale_3rds\",\n            name = \"Major Scale 3rds\",\n            description = \"All 3rd intervals within major scale (M3 and m3)\",\n            difficulty = 2,\n            tags = {\"intervals\", \"scale\", \"3rds\", \"diatonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 4, octave = 4}},   -- Do-Mi (M3)\n                [2] = {{note = 2, octave = 4}, {note = 5, octave = 4}},   -- Re-Fa (m3)\n                [3] = {{note = 4, octave = 4}, {note = 7, octave = 4}},   -- Mi-Sol (m3)\n                [4] = {{note = 5, octave = 4}, {note = 9, octave = 4}},   -- Fa-La (M3)\n                [5] = {{note = 7, octave = 4}, {note = 11, octave = 4}},  -- Sol-Ti (M3)\n                [6] = {{note = 9, octave = 4}, {note = 12, octave = 4}},  -- La-Do (m3)\n                [7] = {{note = 11, octave = 4}, {note = 2, octave = 5}},  -- Ti-Re (m3)\n                [8] = {{note = 0, octave = 4}, {note = 4, octave = 4}}    -- Do-Mi (repeat)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 95,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_major_scale_4ths\",\n            name = \"Major Scale 4ths\",\n            description = \"All 4th intervals within major scale (P4 and tritone)\",\n            difficulty = 2,\n            tags = {\"intervals\", \"scale\", \"4ths\", \"diatonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 5, octave = 4}},   -- Do-Fa (P4)\n                [2] = {{note = 2, octave = 4}, {note = 7, octave = 4}},   -- Re-Sol (P4)\n                [3] = {{note = 4, octave = 4}, {note = 9, octave = 4}},   -- Mi-La (P4)\n                [4] = {{note = 5, octave = 4}, {note = 11, octave = 4}},  -- Fa-Ti (tritone)\n                [5] = {{note = 7, octave = 4}, {note = 12, octave = 4}},  -- Sol-Do (P4)\n                [6] = {{note = 9, octave = 4}, {note = 2, octave = 5}},   -- La-Re (P4)\n                [7] = {{note = 11, octave = 4}, {note = 4, octave = 5}},  -- Ti-Mi (P4)\n                [8] = {{note = 0, octave = 4}, {note = 5, octave = 4}}    -- Do-Fa (repeat)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_major_scale_5ths\",\n            name = \"Major Scale 5ths\",\n            description = \"All 5th intervals within major scale (P5 and tritone)\",\n            difficulty = 2,\n            tags = {\"intervals\", \"scale\", \"5ths\", \"diatonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 7, octave = 4}},   -- Do-Sol (P5)\n                [2] = {{note = 2, octave = 4}, {note = 9, octave = 4}},   -- Re-La (P5)\n                [3] = {{note = 4, octave = 4}, {note = 11, octave = 4}},  -- Mi-Ti (P5)\n                [4] = {{note = 5, octave = 4}, {note = 12, octave = 4}},  -- Fa-Do (P5)\n                [5] = {{note = 7, octave = 4}, {note = 2, octave = 5}},   -- Sol-Re (P5)\n                [6] = {{note = 9, octave = 4}, {note = 4, octave = 5}},   -- La-Mi (P5)\n                [7] = {{note = 11, octave = 4}, {note = 5, octave = 5}},  -- Ti-Fa (tritone)\n                [8] = {{note = 0, octave = 4}, {note = 7, octave = 4}}    -- Do-Sol (repeat)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_major_scale_6ths\",\n            name = \"Major Scale 6ths\",\n            description = \"All 6th intervals within major scale (M6 and m6)\",\n            difficulty = 2,\n            tags = {\"intervals\", \"scale\", \"6ths\", \"diatonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 9, octave = 4}},   -- Do-La (M6)\n                [2] = {{note = 2, octave = 4}, {note = 11, octave = 4}},  -- Re-Ti (M6)\n                [3] = {{note = 4, octave = 4}, {note = 12, octave = 4}},  -- Mi-Do (m6)\n                [4] = {{note = 5, octave = 4}, {note = 2, octave = 5}},   -- Fa-Re (M6)\n                [5] = {{note = 7, octave = 4}, {note = 4, octave = 5}},   -- Sol-Mi (M6)\n                [6] = {{note = 9, octave = 4}, {note = 5, octave = 5}},   -- La-Fa (m6)\n                [7] = {{note = 11, octave = 4}, {note = 7, octave = 5}},  -- Ti-Sol (m6)\n                [8] = {{note = 0, octave = 4}, {note = 9, octave = 4}}    -- Do-La (repeat)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_major_scale_7ths\",\n            name = \"Major Scale 7ths\",\n            description = \"All 7th intervals within major scale (M7 and m7)\",\n            difficulty = 2,\n            tags = {\"intervals\", \"scale\", \"7ths\", \"diatonic\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 11, octave = 4}},  -- Do-Ti (M7)\n                [2] = {{note = 2, octave = 4}, {note = 12, octave = 4}},  -- Re-Do (m7)\n                [3] = {{note = 4, octave = 4}, {note = 2, octave = 5}},   -- Mi-Re (m7)\n                [4] = {{note = 5, octave = 4}, {note = 4, octave = 5}},   -- Fa-Mi (M7)\n                [5] = {{note = 7, octave = 4}, {note = 5, octave = 5}},   -- Sol-Fa (m7)\n                [6] = {{note = 9, octave = 4}, {note = 7, octave = 5}},   -- La-Sol (m7)\n                [7] = {{note = 11, octave = 4}, {note = 9, octave = 5}},  -- Ti-La (m7)\n                [8] = {{note = 0, octave = 4}, {note = 11, octave = 4}}   -- Do-Ti (repeat)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 85,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_major_scale_complete\",\n            name = \"Complete Major Scale Intervals\",\n            description = \"All unique interval types in major scale across 16 sequences\",\n            difficulty = 3,\n            tags = {\"intervals\", \"scale\", \"comprehensive\", \"diatonic\"},\n            sequences = {\n                -- Unison and Octave\n                [1] = {{note = 0, octave = 4}, {note = 0, octave = 4}},   -- Unison\n                [2] = {{note = 0, octave = 4}, {note = 12, octave = 4}},  -- Octave\n                -- 2nds\n                [3] = {{note = 0, octave = 4}, {note = 2, octave = 4}},   -- M2 (Do-Re)\n                [4] = {{note = 4, octave = 4}, {note = 5, octave = 4}},   -- m2 (Mi-Fa)\n                -- 3rds\n                [5] = {{note = 0, octave = 4}, {note = 4, octave = 4}},   -- M3 (Do-Mi)\n                [6] = {{note = 2, octave = 4}, {note = 5, octave = 4}},   -- m3 (Re-Fa)\n                -- 4ths and 5ths\n                [7] = {{note = 0, octave = 4}, {note = 5, octave = 4}},   -- P4 (Do-Fa)\n                [8] = {{note = 5, octave = 4}, {note = 11, octave = 4}},  -- Tritone (Fa-Ti)\n                [9] = {{note = 0, octave = 4}, {note = 7, octave = 4}},   -- P5 (Do-Sol)\n                -- 6ths\n                [10] = {{note = 0, octave = 4}, {note = 9, octave = 4}},  -- M6 (Do-La)\n                [11] = {{note = 4, octave = 4}, {note = 12, octave = 4}}, -- m6 (Mi-Do)\n                -- 7ths\n                [12] = {{note = 0, octave = 4}, {note = 11, octave = 4}}, -- M7 (Do-Ti)\n                [13] = {{note = 2, octave = 4}, {note = 12, octave = 4}}, -- m7 (Re-Do)\n                -- Common compound intervals\n                [14] = {{note = 0, octave = 4}, {note = 2, octave = 5}},  -- M9 (Do-Re octave up)\n                [15] = {{note = 0, octave = 4}, {note = 7, octave = 5}},  -- P12 (Do-Sol octave up)\n                [16] = {{note = 0, octave = 4}, {note = 4, octave = 5}}   -- M10 (Do-Mi octave up)\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2},\n            settings = {\n                tempo = 85,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"intervals_from_tonic\",\n            name = \"Intervals from Tonic (Do)\",\n            description = \"All scale degrees ascending from Do: unison through octave\",\n            difficulty = 1,\n            tags = {\"intervals\", \"tonic\", \"scale-degrees\", \"ascending\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 0, octave = 4}},   -- Do-Do (unison)\n                [2] = {{note = 0, octave = 4}, {note = 2, octave = 4}},   -- Do-Re (M2)\n                [3] = {{note = 0, octave = 4}, {note = 4, octave = 4}},   -- Do-Mi (M3)\n                [4] = {{note = 0, octave = 4}, {note = 5, octave = 4}},   -- Do-Fa (P4)\n                [5] = {{note = 0, octave = 4}, {note = 7, octave = 4}},   -- Do-Sol (P5)\n                [6] = {{note = 0, octave = 4}, {note = 9, octave = 4}},   -- Do-La (M6)\n                [7] = {{note = 0, octave = 4}, {note = 11, octave = 4}},  -- Do-Ti (M7)\n                [8] = {{note = 0, octave = 4}, {note = 12, octave = 4}},  -- Do-Do' (octave)\n                [9] = {{note = 0, octave = 4}, {note = 0, octave = 4}},   -- Repeat for practice\n                [10] = {{note = 0, octave = 4}, {note = 0, octave = 4}},\n                [11] = {{note = 0, octave = 4}, {note = 0, octave = 4}},\n                [12] = {{note = 0, octave = 4}, {note = 0, octave = 4}},\n                [13] = {{note = 0, octave = 4}, {note = 0, octave = 4}},\n                [14] = {{note = 0, octave = 4}, {note = 0, octave = 4}},\n                [15] = {{note = 0, octave = 4}, {note = 0, octave = 4}},\n                [16] = {{note = 0, octave = 4}, {note = 0, octave = 4}}\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        }\n    },\n\n    -- EAR TRAINING TEMPLATES\n    earTraining = {\n        {\n            id = \"ear_melodic_dictation\",\n            name = \"Melodic Dictation\",\n            description = \"4-note melodies ready for dictation practice\",\n            difficulty = 2,\n            tags = {\"dictation\", \"melody\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 5, octave = 4}\n                },\n                [2] = {\n                    {note = 7, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 2, octave = 4}\n                },\n                [3] = {\n                    {note = 0, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 12, octave = 4}\n                }\n            },\n            sequenceLengths = {4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_melodic_dictation_minor\",\n            name = \"Melodic Dictation (Minor)\",\n            description = \"Short minor-key phrases for aural dictation practice\",\n            difficulty = 3,\n            tags = {\"dictation\", \"melody\", \"minor\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 3, octave = 4},\n                    {note = 5, octave = 4}\n                },\n                [2] = {\n                    {note = 7, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 3, octave = 4},\n                    {note = 2, octave = 4}\n                },\n                [3] = {\n                    {note = 0, octave = 4},\n                    {note = 3, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 10, octave = 4}\n                }\n            },\n            sequenceLengths = {4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 88,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_functional_phrases\",\n            name = \"Functional Phrases\",\n            description = \"Melodic phrases outlining tonic, predominant, and dominant function\",\n            difficulty = 3,\n            tags = {\"dictation\", \"melody\", \"functional\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 9, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 0, octave = 4}\n                },\n                [2] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 11, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 0, octave = 4}\n                },\n                [3] = {\n                    {note = 4, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 9, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 2, octave = 4}\n                }\n            },\n            sequenceLengths = {8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 84,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_call_response\",\n            name = \"Call & Response\",\n            description = \"Sequence 1 is 'call', sequence 2 empty for your 'response'\",\n            difficulty = 2,\n            tags = {\"call-response\", \"improvisation\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 13, octave = 4},\n                    {note = 13, octave = 4},\n                    {note = 13, octave = 4},\n                    {note = 13, octave = 4}\n                },\n                [2] = {}\n            },\n            sequenceLengths = {8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 100,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 2\n            }\n        },\n        {\n            id = \"ear_rhythm_subdivision\",\n            name = \"Rhythm Subdivision Drill\",\n            description = \"Quarter, eighth, and sixteenth subdivisions on a single pitch\",\n            difficulty = 2,\n            tags = {\"rhythm\", \"subdivision\", \"ear-training\"},\n            sequences = {\n                [1] = { -- Quarter notes\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}\n                },\n                [2] = { -- Eighth notes\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4}\n                },\n                [3] = { -- Sixteenth notes\n                    {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4},\n                    {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4},\n                    {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4},\n                    {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4}, {note = 0, octave = 4}\n                }\n            },\n            sequenceLengths = {16, 16, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 96,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_rhythm_syncopation\",\n            name = \"Syncopation Patterns\",\n            description = \"Off-beat accents for clapping or tapping back\",\n            difficulty = 3,\n            tags = {\"rhythm\", \"syncopation\", \"ear-training\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4},\n                    {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4},\n                    {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4}\n                },\n                [2] = {\n                    {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4},\n                    {note = 13, octave = 4}, {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4},\n                    {note = 0, octave = 4}, {note = 13, octave = 4}, {note = 13, octave = 4}, {note = 0, octave = 4}\n                }\n            },\n            sequenceLengths = {16, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 104,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_leading_tone_resolution\",\n            name = \"Leading Tone Resolution\",\n            description = \"Hear Ti resolving to Do across multiple registers\",\n            difficulty = 2,\n            tags = {\"functional\", \"resolution\", \"scale-degrees\"},\n            sequences = {\n                [1] = {{note = 11, octave = 4}, {note = 12, octave = 4}},\n                [2] = {{note = 11, octave = 3}, {note = 12, octave = 3}},\n                [3] = {{note = 11, octave = 5}, {note = 12, octave = 5}}\n            },\n            sequenceLengths = {2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 88,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_chord_quality\",\n            name = \"Chord Quality Recognition\",\n            description = \"Major, minor, diminished, and augmented chords\",\n            difficulty = 3,\n            tags = {\"chords\", \"quality\", \"quiz\"},\n            sequences = {\n                [1] = {{notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}}},  -- Major\n                [2] = {{notes = {{note = 0, octave = 4}, {note = 3, octave = 4}, {note = 7, octave = 4}}}},  -- Minor\n                [3] = {{notes = {{note = 0, octave = 4}, {note = 3, octave = 4}, {note = 6, octave = 4}}}},  -- Diminished\n                [4] = {{notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 8, octave = 4}}}}   -- Augmented\n            },\n            sequenceLengths = {1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 80,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_scale_degree\",\n            name = \"Scale Degree Identification\",\n            description = \"Identify which degree of the scale is playing\",\n            difficulty = 2,\n            tags = {\"scale-degrees\", \"solfege\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}},  -- Do\n                [2] = {{note = 2, octave = 4}},  -- Re\n                [3] = {{note = 4, octave = 4}},  -- Mi\n                [4] = {{note = 5, octave = 4}},  -- Fa\n                [5] = {{note = 7, octave = 4}},  -- Sol\n                [6] = {{note = 9, octave = 4}},  -- La\n                [7] = {{note = 11, octave = 4}}, -- Ti\n                [8] = {{note = 12, octave = 4}}  -- Do'\n            },\n            sequenceLengths = {1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 100,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, true, true, true, true, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_seventh_chord_quality\",\n            name = \"Seventh Chord Quality Recognition\",\n            description = \"Identify major 7, dominant 7, minor 7, and half-diminished chords\",\n            difficulty = 4,\n            tags = {\"chords\", \"quality\", \"seventh\", \"quiz\"},\n            sequences = {\n                [1] = {{notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}, {note = 11, octave = 4}}}}, -- Maj7\n                [2] = {{notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}, {note = 10, octave = 4}}}}, -- Dom7\n                [3] = {{notes = {{note = 0, octave = 4}, {note = 3, octave = 4}, {note = 7, octave = 4}, {note = 10, octave = 4}}}}, -- Min7\n                [4] = {{notes = {{note = 0, octave = 4}, {note = 3, octave = 4}, {note = 6, octave = 4}, {note = 10, octave = 4}}}}  -- Half-diminished\n            },\n            sequenceLengths = {1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 72,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_chord_inversions\",\n            name = \"Triad Inversions\",\n            description = \"Identify root position vs inversions for major and minor triads\",\n            difficulty = 3,\n            tags = {\"chords\", \"inversions\", \"ear-training\"},\n            sequences = {\n                [1] = {{notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}}},  -- C major root\n                [2] = {{notes = {{note = 4, octave = 4}, {note = 7, octave = 4}, {note = 0, octave = 5}}}},  -- C major 1st inv\n                [3] = {{notes = {{note = 7, octave = 4}, {note = 0, octave = 5}, {note = 4, octave = 5}}}},  -- C major 2nd inv\n                [4] = {{notes = {{note = 0, octave = 4}, {note = 3, octave = 4}, {note = 7, octave = 4}}}},  -- C minor root\n                [5] = {{notes = {{note = 3, octave = 4}, {note = 7, octave = 4}, {note = 0, octave = 5}}}},  -- C minor 1st inv\n                [6] = {{notes = {{note = 7, octave = 4}, {note = 0, octave = 5}, {note = 3, octave = 5}}}}   -- C minor 2nd inv\n            },\n            sequenceLengths = {1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 80,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, true, true, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_harmonic_progressions\",\n            name = \"Harmonic Progressions\",\n            description = \"Recognize common progressions like I-IV-V-I and ii-V-I\",\n            difficulty = 4,\n            tags = {\"progressions\", \"harmony\", \"quiz\"},\n            sequences = {\n                [1] = { -- I-IV-V-I\n                    {notes = {{note = 0, octave = 3}, {note = 4, octave = 4}, {note = 7, octave = 4}}},\n                    {notes = {{note = 5, octave = 3}, {note = 9, octave = 4}, {note = 0, octave = 4}}},\n                    {notes = {{note = 7, octave = 3}, {note = 11, octave = 4}, {note = 2, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}}\n                },\n                [2] = { -- ii-V-I\n                    {notes = {{note = 2, octave = 3}, {note = 5, octave = 4}, {note = 9, octave = 4}}},\n                    {notes = {{note = 7, octave = 3}, {note = 11, octave = 4}, {note = 2, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}}\n                }\n            },\n            sequenceLengths = {4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 68,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_cadence_recognition\",\n            name = \"Cadence Recognition\",\n            description = \"Identify authentic, plagal, half, and deceptive cadences\",\n            difficulty = 3,\n            tags = {\"cadence\", \"harmony\", \"quiz\"},\n            sequences = {\n                [1] = { -- Authentic cadence (V-I)\n                    {notes = {{note = 7, octave = 3}, {note = 11, octave = 4}, {note = 2, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}}\n                },\n                [2] = { -- Plagal cadence (IV-I)\n                    {notes = {{note = 5, octave = 3}, {note = 9, octave = 4}, {note = 0, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}}\n                },\n                [3] = { -- Half cadence (I-V)\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}},\n                    {notes = {{note = 7, octave = 3}, {note = 11, octave = 4}, {note = 2, octave = 4}}}\n                },\n                [4] = { -- Deceptive cadence (V-vi)\n                    {notes = {{note = 7, octave = 3}, {note = 11, octave = 4}, {note = 2, octave = 4}}},\n                    {notes = {{note = 9, octave = 3}, {note = 0, octave = 4}, {note = 4, octave = 4}}}\n                }\n            },\n            sequenceLengths = {2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 70,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_melodic_dictation_intermediate\",\n            name = \"Melodic Dictation (Intermediate)\",\n            description = \"8-note melodies for longer dictation practice\",\n            difficulty = 3,\n            tags = {\"dictation\", \"melody\", \"intermediate\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 0, octave = 4}\n                },\n                [2] = {\n                    {note = 7, octave = 4},\n                    {note = 9, octave = 4},\n                    {note = 11, octave = 4},\n                    {note = 12, octave = 4},\n                    {note = 9, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 4, octave = 4}\n                },\n                [3] = {\n                    {note = 4, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 9, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 2, octave = 4}\n                }\n            },\n            sequenceLengths = {8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = 0,\n                droneOctave = 3,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_accapella_solfege_steps\",\n            name = \"A Cappella Solfege Steps\",\n            description = \"Stepwise solfege practice without drone accompaniment\",\n            difficulty = 2,\n            tags = {\"accapella\", \"solfege\", \"steps\", \"ear-training\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 9, octave = 4},\n                    {note = 11, octave = 4},\n                    {note = 12, octave = 4}\n                }\n            },\n            sequenceLengths = {8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_accapella_interval_pairs\",\n            name = \"A Cappella Interval Pairs\",\n            description = \"Isolated interval pairs for unaccompanied ear training\",\n            difficulty = 2,\n            tags = {\"accapella\", \"intervals\", \"ear-training\"},\n            sequences = {\n                [1] = {{note = 0, octave = 4}, {note = 1, octave = 4}},  -- m2\n                [2] = {{note = 0, octave = 4}, {note = 2, octave = 4}},  -- M2\n                [3] = {{note = 0, octave = 4}, {note = 3, octave = 4}},  -- m3\n                [4] = {{note = 0, octave = 4}, {note = 4, octave = 4}},  -- M3\n                [5] = {{note = 0, octave = 4}, {note = 5, octave = 4}},  -- P4\n                [6] = {{note = 0, octave = 4}, {note = 7, octave = 4}}   -- P5\n            },\n            sequenceLengths = {2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 80,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = true,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {true, true, true, true, true, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"ear_accapella_echo_phrase\",\n            name = \"A Cappella Echo Phrase\",\n            description = \"Call-and-response phrase with space to sing back\",\n            difficulty = 2,\n            tags = {\"accapella\", \"call-response\", \"ear-training\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 13, octave = 4},\n                    {note = 13, octave = 4},\n                    {note = 13, octave = 4},\n                    {note = 13, octave = 4}\n                },\n                [2] = {}\n            },\n            sequenceLengths = {8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 95,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 2\n            }\n        }\n    },\n\n    -- COMPOSITION TEMPLATES\n    composition = {\n        {\n            id = \"comp_verse_chorus\",\n            name = \"Verse-Chorus Structure\",\n            description = \"Sequences 1-4 for verse, 5-8 for chorus\",\n            difficulty = 2,\n            tags = {\"song-structure\", \"pop\"},\n            sequences = {},\n            sequenceLengths = {16, 16, 16, 16, 16, 16, 16, 16, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 120,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, true, true, true, true, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"comp_blues_12bar\",\n            name = \"Blues 12-Bar\",\n            description = \"Classic 12-bar blues structure\",\n            difficulty = 3,\n            tags = {\"blues\", \"progression\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 3}, {note = 0, octave = 3}, {note = 0, octave = 3}, {note = 0, octave = 3},\n                    {note = 5, octave = 3}, {note = 5, octave = 3}, {note = 0, octave = 3}, {note = 0, octave = 3},\n                    {note = 7, octave = 3}, {note = 5, octave = 3}, {note = 0, octave = 3}, {note = 7, octave = 3}\n                }\n            },\n            sequenceLengths = {12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 90,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"comp_canon\",\n            name = \"Canon Template\",\n            description = \"Multi-voice canon structure with offset entrances\",\n            difficulty = 3,\n            tags = {\"canon\", \"counterpoint\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 2, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 5, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 9, octave = 4},\n                    {note = 11, octave = 4},\n                    {note = 12, octave = 4}\n                }\n            },\n            sequenceLengths = {8, 8, 8, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 100,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, true, true, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 1, 2, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"comp_jazz_ii_v_i\",\n            name = \"Jazz ii-V-I\",\n            description = \"Standard jazz progression with guide tones\",\n            difficulty = 3,\n            tags = {\"jazz\", \"progression\"},\n            sequences = {\n                [1] = {\n                    {notes = {{note = 2, octave = 4}, {note = 5, octave = 4}, {note = 9, octave = 4}}},  -- ii (Dm)\n                    {notes = {{note = 2, octave = 4}, {note = 5, octave = 4}, {note = 9, octave = 4}}},\n                    {notes = {{note = 7, octave = 3}, {note = 11, octave = 4}, {note = 2, octave = 4}}}, -- V (G7)\n                    {notes = {{note = 7, octave = 3}, {note = 11, octave = 4}, {note = 2, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}},  -- I (C)\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}},\n                    {notes = {{note = 0, octave = 4}, {note = 4, octave = 4}, {note = 7, octave = 4}}}\n                }\n            },\n            sequenceLengths = {8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 120,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"comp_minimalist\",\n            name = \"Minimalist Pattern\",\n            description = \"Simple repeating patterns for minimalist composition\",\n            difficulty = 2,\n            tags = {\"minimalism\", \"repetition\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 4},\n                    {note = 7, octave = 4},\n                    {note = 0, octave = 4},\n                    {note = 7, octave = 4}\n                },\n                [2] = {\n                    {note = 4, octave = 4},\n                    {note = 11, octave = 4},\n                    {note = 4, octave = 4},\n                    {note = 11, octave = 4}\n                }\n            },\n            sequenceLengths = {4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 140,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 1, 0, 0, 0, 0, 0, 0},\n                activeIndex = 1\n            }\n        },\n        {\n            id = \"comp_bass_melody\",\n            name = \"Bass + Melody\",\n            description = \"Template with bass line in seq 1, melody in seq 2\",\n            difficulty = 2,\n            tags = {\"bass\", \"melody\", \"two-part\"},\n            sequences = {\n                [1] = {\n                    {note = 0, octave = 3},\n                    {note = 7, octave = 2},\n                    {note = 0, octave = 3},\n                    {note = 7, octave = 2},\n                    {note = 5, octave = 3},\n                    {note = 0, octave = 3},\n                    {note = 7, octave = 2},\n                    {note = 0, octave = 3}\n                }\n            },\n            sequenceLengths = {8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            settings = {\n                tempo = 110,\n                rootNote = 0,\n                droneNoteSelection = -1,\n                droneOctave = 4,\n                randomizeRootPlayback = false,\n                randomizeOctavePlayback = false\n            },\n            sequenceStates = {\n                mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n                octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n                activeIndex = 2\n            }\n        }\n    },\n\n    -- PATTERN-BASED TEMPLATES (converted from patterns_embedded.lua)\n    -- These are loaded from the patterns module\n    majorScales = {},\n    majorExercises = {},\n    naturalMinor = {},\n    pentatonics = {},\n    allOctaves = {},\n    tonalCenter = {},\n    scalesAndModes = {},\n    arpeggiosAndChords = {}\n}\n\n-- Helper function to convert pattern to template format\nlocal function patternToTemplate(pattern, category, defaultOctave)\n    defaultOctave = defaultOctave or 4\n    local sequence = {}\n    for _, noteIndex in ipairs(pattern.notes) do\n        table.insert(sequence, {note = noteIndex, octave = defaultOctave})\n    end\n\n    return {\n        id = category .. \"_\" .. pattern.name:gsub(\" \", \"_\"):gsub(\"[^%w_]\", \"\"):lower(),\n        name = pattern.name,\n        description = \"Pattern: \" .. pattern.name,\n        difficulty = 2,\n        tags = {category, \"pattern\"},\n        patternType = category,\n        patternGroup = pattern.group,\n        sequences = {[1] = sequence},\n        sequenceLengths = {#sequence, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n        settings = {\n            tempo = 100,\n            rootNote = 0,\n            droneNoteSelection = 0,\n            droneOctave = 3,\n            randomizeRootPlayback = false,\n            randomizeOctavePlayback = false\n        },\n        sequenceStates = {\n            mutes = {false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false},\n            octaveTransposes = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},\n            activeIndex = 1\n        }\n    }\nend\n\n-- Pattern data embedded directly (no longer needs patterns_embedded.lua)\nlocal patternData = {\n    majorScales = {\n        {name = \"Do-Do (Unison)\", notes = {0, 0}},\n        {name = \"Do-Re\", notes = {0, 2}},\n        {name = \"Do-Mi\", notes = {0, 2, 4}},\n        {name = \"Do-Fa\", notes = {0, 2, 4, 5}},\n        {name = \"Do-Sol\", notes = {0, 2, 4, 5, 7}},\n        {name = \"Do-La\", notes = {0, 2, 4, 5, 7, 9}},\n        {name = \"Do-Ti\", notes = {0, 2, 4, 5, 7, 9, 11}},\n        {name = \"Do-Do'\", notes = {0, 2, 4, 5, 7, 9, 11, 12}},\n        {name = \"Major Scale Ascending\", notes = {0, 2, 4, 5, 7, 9, 11, 12}},\n        {name = \"Major Scale Descending\", notes = {12, 11, 9, 7, 5, 4, 2, 0}},\n        {name = \"Sequential Major Scale\", notes = {0, 2, 4, 5, 7, 9, 11, 12, 11, 9, 7, 5, 4, 2}},\n        {name = \"Up & Down Major Scale\", notes = {0, 2, 4, 5, 7, 9, 11, 12, 11, 9, 7, 5, 4, 2, 0}},\n        {name = \"Major Scale Round Trip\", notes = {0, 2, 4, 5, 7, 9, 11, 12, 12, 11, 9, 7, 5, 4, 2, 0}},\n        {name = \"Major Scale in Thirds\", notes = {0, 4, 2, 5, 4, 7, 5, 9, 7, 11, 9, 12}},\n        {name = \"Major Scale in Fourths\", notes = {0, 5, 2, 7, 4, 9, 5, 11, 7, 12}},\n        {name = \"Major Scale in Fifths\", notes = {0, 7, 2, 9, 4, 11, 5, 12}},\n        {name = \"Major Scale in Sixths\", notes = {0, 9, 2, 11, 4, 12}},\n        {name = \"Major Scale Tetrachords\", notes = {0, 2, 4, 5, 7, 9, 11, 12}},\n    },\n    naturalMinorScales = {\n        {name = \"Natural Minor Ascending\", notes = {0, 2, 3, 5, 7, 8, 10, 12}},\n        {name = \"Natural Minor Descending\", notes = {12, 10, 8, 7, 5, 3, 2, 0}},\n        {name = \"Sequential Natural Minor\", notes = {0, 2, 3, 5, 7, 8, 10, 12, 10, 8, 7, 5, 3, 2}},\n        {name = \"Up & Down Natural Minor\", notes = {0, 2, 3, 5, 7, 8, 10, 12, 10, 8, 7, 5, 3, 2, 0}},\n        {name = \"Natural Minor Round Trip\", notes = {0, 2, 3, 5, 7, 8, 10, 12, 12, 10, 8, 7, 5, 3, 2, 0}},\n        {name = \"Natural Minor in Thirds\", notes = {0, 3, 2, 5, 3, 7, 5, 8, 7, 10, 8, 12}},\n        {name = \"Natural Minor in Fourths\", notes = {0, 5, 2, 7, 3, 8, 5, 10, 7, 12}},\n        {name = \"Natural Minor in Fifths\", notes = {0, 7, 2, 8, 3, 10, 5, 12}},\n        {name = \"Natural Minor in Sixths\", notes = {0, 8, 2, 10, 3, 12}},\n        {name = \"Natural Minor Tetrachords\", notes = {0, 2, 3, 5, 7, 8, 10, 12}},\n        {name = \"Ladder (Natural Minor)\", notes = {0, 2, 0, 0, 2, 3, 2, 0, 0, 2, 3, 5, 3, 2, 0, 0, 2, 3, 5, 7, 5, 3, 2, 0, 0, 2, 3, 5, 7, 8, 7, 5, 3, 2, 0, 0, 2, 3, 5, 7, 8, 10, 8, 7, 5, 3, 2, 0, 0, 2, 3, 5, 7, 8, 10, 12, 10, 8, 7, 5, 3, 2, 0}},\n        {name = \"Pyramid (Natural Minor)\", notes = {0, 2, 0, 2, 3, 2, 0, 2, 3, 5, 3, 2, 0, 2, 3, 5, 7, 5, 3, 2, 0, 2, 3, 5, 7, 8, 7, 5, 3, 2, 0, 2, 3, 5, 7, 8, 10, 8, 7, 5, 3, 2, 0, 2, 3, 5, 7, 8, 10, 12, 10, 8, 7, 5, 3, 2, 0}},\n        {name = \"Tonic Return Ascent (Natural Minor)\", notes = {0, 2, 0, 0, 3, 0, 0, 5, 0, 0, 7, 0, 0, 8, 0, 0, 10, 0, 0, 12, 0}},\n        {name = \"Octave Return Descent (Natural Minor)\", notes = {12, 10, 12, 8, 12, 7, 12, 5, 12, 3, 12, 2, 12, 0}},\n        {name = \"Tonic Return with Rests (Natural Minor)\", notes = {0, 2, 13, 0, 3, 13, 0, 0, 5, 13, 0, 7, 13, 0, 8, 13, 0, 10, 13, 0, 12, 13}},\n        {name = \"Ascending Intervals (Natural Minor)\", notes = {0, 2, 0, 3, 0, 5, 0, 7, 0, 8, 0, 10, 0, 12}},\n        {name = \"Ascending Intervals Rest End (Natural Minor)\", notes = {0, 2, 0, 3, 0, 5, 0, 7, 0, 8, 0, 10, 0, 12, 13}},\n        {name = \"Descending Intervals (Natural Minor)\", notes = {12, 10, 12, 8, 12, 7, 12, 5, 12, 3, 12, 2, 12, 0}},\n        {name = \"Triad Outlines (Natural Minor)\", notes = {0, 3, 7, 3, 0, 5, 8, 12, 8, 5, 0}},\n        {name = \"Neighbor Tones (Natural Minor)\", notes = {0, 1, 0, 2, 0, 3, 0, 5, 0, 7, 0, 8, 0, 10, 0, 12, 0}},\n        {name = \"Arch (Natural Minor)\", notes = {0, 2, 3, 5, 7, 5, 3, 2, 0}},\n        {name = \"Interlocking Thirds (Natural Minor)\", notes = {0, 3, 2, 5, 3, 7, 5, 8, 7, 10, 8, 12}},\n        {name = \"Tonic Interval Hops (Natural Minor)\", notes = {0, 3, 0, 5, 0, 7, 0, 8, 0}},\n        {name = \"Scale Bounces (Natural Minor)\", notes = {0, 2, 0, 3, 2, 5, 3, 7, 5, 8, 7, 10, 8, 12}},\n        {name = \"Zigzag (Natural Minor)\", notes = {0, 3, 2, 5, 3, 7, 5, 8, 7, 10, 8, 12, 10, 8, 7, 5, 3, 2, 0}},\n        {name = \"Broken Triads (Natural Minor)\", notes = {0, 3, 7, 12, 2, 5, 8, 12, 3, 7, 10, 12}},\n        {name = \"Stepwise Thirds (Natural Minor)\", notes = {0, 2, 3, 2, 3, 5, 7, 5, 7, 8, 10, 8, 10, 12}},\n        {name = \"Arpeggiated Scale (Natural Minor)\", notes = {0, 3, 2, 5, 3, 7, 5, 8, 7, 10, 8, 12}},\n        {name = \"Sequential Thirds (Natural Minor)\", notes = {0, 3, 2, 5, 3, 7, 5, 8, 7, 10, 8, 12}},\n        {name = \"Octave Leaps (Natural Minor)\", notes = {0, 12, 2, 12, 3, 12, 5, 12, 7, 12}},\n        {name = \"Minor Seventh Chords\", notes = {0, 3, 7, 10, 2, 5, 8, 12}},\n    },\n    majorExercises = {\n        {name = \"Ladder (Major Scale)\", notes = {0, 2, 0, 0, 2, 4, 2, 0, 0, 2, 4, 5, 4, 2, 0, 0, 2, 4, 5, 7, 5, 4, 2, 0, 0, 2, 4, 5, 7, 9, 7, 5, 4, 2, 0, 0, 2, 4, 5, 7, 9, 11, 9, 7, 5, 4, 2, 0, 0, 2, 4, 5, 7, 9, 11, 12, 11, 9, 7, 5, 4, 2, 0}, group = \"scale_patterns\"},\n        {name = \"Pyramid (Major Scale)\", notes = {0, 2, 0, 2, 4, 2, 0, 2, 4, 5, 4, 2, 0, 2, 4, 5, 7, 5, 4, 2, 0, 2, 4, 5, 7, 9, 7, 5, 4, 2, 0, 2, 4, 5, 7, 9, 11, 9, 7, 5, 4, 2, 0, 2, 4, 5, 7, 9, 11, 12, 11, 9, 7, 5, 4, 2, 0}, group = \"scale_patterns\"},\n        {name = \"Tonic Return Ascent (Major)\", notes = {0, 2, 0, 0, 4, 0, 0, 5, 0, 0, 7, 0, 0, 9, 0, 0, 11, 0, 0, 12, 0}, group = \"scale_patterns\"},\n        {name = \"Octave Return Descent (Major)\", notes = {12, 11, 12, 9, 12, 7, 12, 5, 12, 4, 12, 2, 12, 0}, group = \"scale_patterns\"},\n        {name = \"Tonic Return with Rests (Major)\", notes = {0, 2, 13, 0, 4, 13, 0, 0, 5, 13, 0, 7, 13, 0, 9, 13, 0, 11, 13, 0, 12, 13}, group = \"scale_patterns\"},\n        {name = \"Ascending Intervals\", notes = {0, 2, 0, 4, 0, 5, 0, 7, 0, 9, 0, 11, 0, 12}, group = \"intervals_harmony\"},\n        {name = \"Ascending Intervals (Rest End)\", notes = {0, 2, 0, 4, 0, 5, 0, 7, 0, 9, 0, 11, 0, 12, 13}, group = \"intervals_harmony\"},\n        {name = \"Descending Intervals\", notes = {12, 11, 12, 9, 12, 7, 12, 5, 12, 4, 12, 2, 12, 0}, group = \"intervals_harmony\"},\n        {name = \"Triad Outlines\", notes = {0, 4, 7, 4, 0, 5, 9, 12, 9, 5, 0}, group = \"intervals_harmony\"},\n        {name = \"Neighbor Tones (Major)\", notes = {0, 1, 0, 2, 0, 4, 0, 5, 0, 7, 0, 9, 0, 11, 0, 12, 0}, group = \"intervals_harmony\"},\n        {name = \"Arch (Major)\", notes = {0, 2, 4, 5, 7, 5, 4, 2, 0}, group = \"scale_patterns\"},\n        {name = \"Interlocking Thirds (Major)\", notes = {0, 4, 2, 5, 4, 7, 5, 9, 7, 11, 9, 12}, group = \"intervals_harmony\"},\n        {name = \"Tonic Interval Hops (Major)\", notes = {0, 4, 0, 5, 0, 7, 0, 9, 0}, group = \"intervals_harmony\"},\n        {name = \"Scale Bounces (Major)\", notes = {0, 2, 0, 4, 2, 5, 4, 7, 5, 9, 7, 11, 9, 12}, group = \"scale_patterns\"},\n        {name = \"Zigzag (Major)\", notes = {0, 4, 2, 5, 4, 7, 5, 9, 7, 11, 9, 12, 11, 9, 7, 5, 4, 2, 0}, group = \"scale_patterns\"},\n        {name = \"Broken Triads (Major)\", notes = {0, 4, 7, 12, 2, 5, 9, 12, 4, 7, 11, 12}, group = \"intervals_harmony\"},\n        {name = \"Stepwise Thirds\", notes = {0, 2, 4, 2, 4, 5, 7, 5, 7, 9, 11, 9, 11, 12}, group = \"intervals_harmony\"},\n        {name = \"Arpeggiated Scale (Major)\", notes = {0, 4, 2, 5, 4, 7, 5, 9, 7, 11, 9, 12}, group = \"intervals_harmony\"},\n        {name = \"Sequential Thirds (Major)\", notes = {0, 4, 2, 5, 4, 7, 5, 9, 7, 11, 9, 12}, group = \"intervals_harmony\"},\n        {name = \"Octave Leaps (Major)\", notes = {0, 12, 2, 12, 4, 12, 5, 12, 7, 12}, group = \"intervals_harmony\"},\n        {name = \"Diatonic Seventh Chords\", notes = {0, 4, 7, 11, 2, 5, 9, 12}, group = \"intervals_harmony\"},\n    },\n    pentatonics = {\n        {name = \"Pentatonic Major\", notes = {0, 2, 4, 7, 9, 12}},\n        {name = \"Pentatonic Minor\", notes = {0, 3, 5, 7, 10, 12}},\n        {name = \"Pentatonic Major Descending\", notes = {12, 9, 7, 4, 2, 0}},\n        {name = \"Pentatonic Minor Descending\", notes = {12, 10, 7, 5, 3, 0}},\n        {name = \"Pentatonic Major Sequence\", notes = {0, 2, 4, 2, 7, 4, 9, 7, 12, 9}},\n        {name = \"Pentatonic Minor Sequence\", notes = {0, 3, 5, 3, 7, 5, 10, 7, 12, 10}},\n        {name = \"Pentatonic Major In Thirds\", notes = {0, 4, 2, 7, 4, 9, 7, 12, 9}},\n        {name = \"Pentatonic Minor In Thirds\", notes = {0, 5, 3, 7, 5, 10, 7, 12, 10}},\n        {name = \"Pentatonic Major Turns\", notes = {0, 2, 0, 4, 2, 7, 4, 9, 7, 12, 9}},\n        {name = \"Pentatonic Minor Turns\", notes = {0, 3, 0, 5, 3, 7, 5, 10, 7, 12, 10}},\n        {name = \"Pentatonic Major Round Trip\", notes = {0, 2, 4, 7, 9, 12, 9, 7, 4, 2, 0}},\n        {name = \"Pentatonic Minor Round Trip\", notes = {0, 3, 5, 7, 10, 12, 10, 7, 5, 3, 0}},\n        {name = \"Pentatonic Major Leaps\", notes = {0, 7, 2, 9, 4, 12}},\n        {name = \"Pentatonic Blues Pattern\", notes = {0, 3, 5, 6, 7, 10, 12}},\n    },\n    intervalProgressions = {\n        {name = \"Seconds Through Scale\", notes = {0, 2, 2, 4, 4, 5, 5, 7, 7, 9, 9, 11, 11, 12}},\n        {name = \"Thirds Through Scale\", notes = {0, 4, 2, 5, 4, 7, 5, 9, 7, 11, 9, 12}},\n        {name = \"Fourths Through Scale\", notes = {0, 5, 2, 7, 4, 9, 5, 11, 7, 12}},\n        {name = \"Fifths Through Scale\", notes = {0, 7, 2, 9, 4, 11, 5, 12}},\n        {name = \"Sixths Through Scale\", notes = {0, 9, 2, 11, 4, 12}},\n        {name = \"Sevenths Through Scale\", notes = {0, 11, 2, 12}},\n        {name = \"Octaves Through Scale\", notes = {0, 12, 2, 12, 4, 12, 5, 12, 7, 12, 9, 12, 11, 12}},\n    },\n    intervalTraining = {\n        {name = \"Mixed Intervals Ascending\", notes = {0, 2, 0, 4, 0, 7, 0, 9, 0, 12}},\n        {name = \"Mixed Intervals Descending\", notes = {12, 9, 12, 7, 12, 4, 12, 2, 12, 0}},\n        {name = \"All Intervals from Tonic Asc\", notes = {0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 0, 8, 0, 9, 0, 10, 0, 11, 0, 12}},\n        {name = \"All Intervals from Tonic Desc\", notes = {12, 11, 12, 10, 12, 9, 12, 8, 12, 7, 12, 6, 12, 5, 12, 4, 12, 3, 12, 2, 12, 1, 12, 0}},\n    },\n    directionalIntervals = {\n        {name = \"Sixths Descending\", notes = {12, 4, 11, 2, 9, 0}},\n        {name = \"Thirds Descending\", notes = {12, 9, 11, 7, 9, 5, 7, 4, 5, 2, 4, 0}},\n        {name = \"Fourths Descending\", notes = {12, 7, 11, 5, 9, 4, 7, 2, 5, 0}},\n        {name = \"Fifths Descending\", notes = {12, 5, 11, 4, 9, 2, 7, 0}},\n    },\n    allOctaves = {\n        {name = \"Unison (Oct 2-6)\", notes = {0, 0, 13, 0, 0, 13, 0, 0, 13, 0, 0, 13, 0, 0, 13}},\n        {name = \"Minor 2nd (Oct 2-6)\", notes = {0, 1, 13, 0, 1, 13, 0, 1, 13, 0, 1, 13, 0, 1, 13}},\n        {name = \"Major 2nd (Oct 2-6)\", notes = {0, 2, 13, 0, 2, 13, 0, 2, 13, 0, 2, 13, 0, 2, 13}},\n        {name = \"Minor 3rd (Oct 2-6)\", notes = {0, 3, 13, 0, 3, 13, 0, 3, 13, 0, 3, 13, 0, 3, 13}},\n        {name = \"Major 3rd (Oct 2-6)\", notes = {0, 4, 13, 0, 4, 13, 0, 4, 13, 0, 4, 13, 0, 4, 13}},\n        {name = \"Perfect 4th (Oct 2-6)\", notes = {0, 5, 13, 0, 5, 13, 0, 5, 13, 0, 5, 13, 0, 5, 13}},\n        {name = \"Tritone (Oct 2-6)\", notes = {0, 6, 13, 0, 6, 13, 0, 6, 13, 0, 6, 13, 0, 6, 13}},\n        {name = \"Perfect 5th (Oct 2-6)\", notes = {0, 7, 13, 0, 7, 13, 0, 7, 13, 0, 7, 13, 0, 7, 13}},\n        {name = \"Minor 6th (Oct 2-6)\", notes = {0, 8, 13, 0, 8, 13, 0, 8, 13, 0, 8, 13, 0, 8, 13}},\n        {name = \"Major 6th (Oct 2-6)\", notes = {0, 9, 13, 0, 9, 13, 0, 9, 13, 0, 9, 13, 0, 9, 13}},\n        {name = \"Minor 7th (Oct 2-6)\", notes = {0, 10, 13, 0, 10, 13, 0, 10, 13, 0, 10, 13, 0, 10, 13}},\n        {name = \"Major 7th (Oct 2-6)\", notes = {0, 11, 13, 0, 11, 13, 0, 11, 13, 0, 11, 13, 0, 11, 13}},\n        {name = \"Octave (Oct 2-6)\", notes = {0, 12, 13, 0, 12, 13, 0, 12, 13, 0, 12, 13, 0, 12, 13}},\n    },\n    tonalCenterAnchors = {\n        {name = \"Tonic Anchor\", notes = {0, 13, 0, 13, 0}},\n        {name = \"Do-Re-Mi\", notes = {0, 2, 4}},\n        {name = \"Do-Re-Mi-Fa\", notes = {0, 2, 4, 5}},\n        {name = \"Do-Re-Mi-Fa-Sol\", notes = {0, 2, 4, 5, 7}},\n        {name = \"Do-Mi-Sol\", notes = {0, 4, 7}},\n        {name = \"Do-Fa-Sol-Do\", notes = {0, 5, 7, 0}},\n        {name = \"Do-Mi-Fa-Sol\", notes = {0, 4, 5, 7}},\n        {name = \"Do-Sol-Do\", notes = {0, 7, 0}},\n        {name = \"Do-Fa-Do\", notes = {0, 5, 0}},\n    },\n    tonalCenterTonicEchoes = {\n        {name = \"Do-Re-Do\", notes = {0, 2, 0}},\n        {name = \"Do-Mi-Do\", notes = {0, 4, 0}},\n        {name = \"Do-Fa-Do\", notes = {0, 5, 0}},\n        {name = \"Do-Sol-Do\", notes = {0, 7, 0}},\n        {name = \"Do-La-Do\", notes = {0, 9, 0}},\n        {name = \"Do-Ti-Do\", notes = {0, 11, 0}},\n        {name = \"Do-Re-Mi-Do\", notes = {0, 2, 4, 0}},\n        {name = \"Do-Mi-Re-Do\", notes = {0, 4, 2, 0}},\n        {name = \"Do-Fa-Mi-Do\", notes = {0, 5, 4, 0}},\n        {name = \"Do-Sol-Fa-Do\", notes = {0, 7, 5, 0}},\n    },\n    tonalCenterMelodies = {\n        {name = \"Do-Sol-Fa-Mi-Re-Do\", notes = {0, 7, 5, 4, 2, 0}},\n        {name = \"Do-Ti-Do'-Ti-Do\", notes = {0, 11, 12, 11, 0}},\n        {name = \"Do-Re-Mi-Fa-Mi-Re-Do\", notes = {0, 2, 4, 5, 4, 2, 0}},\n        {name = \"Do-Mi-Sol-Mi-Do\", notes = {0, 4, 7, 4, 0}},\n        {name = \"Do-Re-Mi-Do-Re-Do\", notes = {0, 2, 4, 0, 2, 0}},\n        {name = \"Do-Fa-Mi-Re-Do\", notes = {0, 5, 4, 2, 0}},\n        {name = \"Do-Sol-Fa-Mi-Fa-Sol-Do\", notes = {0, 7, 5, 4, 5, 7, 0}},\n        {name = \"Do-Ti-La-Sol-Fa-Mi-Re-Do\", notes = {0, 11, 9, 7, 5, 4, 2, 0}},\n        {name = \"Do-Mi-Fa-Sol-Fa-Mi-Re-Do\", notes = {0, 4, 5, 7, 5, 4, 2, 0}},\n        {name = \"Do-Re-Do-Re-Mi-Re-Do\", notes = {0, 2, 0, 2, 4, 2, 0}},\n        {name = \"Do-Mi-Sol-La-Sol-Mi-Do\", notes = {0, 4, 7, 9, 7, 4, 0}},\n        {name = \"Do-Sol-Sol-Fa-Mi-Re-Do\", notes = {0, 7, 7, 5, 4, 2, 0}},\n        {name = \"Do-Re-Mi-Re-Do-Ti-Do\", notes = {0, 2, 4, 2, 0, 11, 0}},\n        {name = \"Do-Mi-Re-Fa-Mi-Sol-Fa-La-Sol-Do'\", notes = {0, 4, 2, 5, 4, 7, 5, 9, 7, 12}},\n        {name = \"Do-Fa-Sol-La-Sol-Fa-Mi-Do\", notes = {0, 5, 7, 9, 7, 5, 4, 0}},\n        {name = \"Do-Re-Mi-Sol-Fa-Re-Do\", notes = {0, 2, 4, 7, 5, 2, 0}},\n    },\n    tonalCenterMajor = {\n        {name = \"Do-Sol-Mi-Do (Major)\", notes = {0, 7, 4, 0}},\n        {name = \"Do-Mi-Fa-Mi-Re-Do (Major)\", notes = {0, 4, 5, 4, 2, 0}},\n        {name = \"Sol-Mi-Do-Mi-Sol (Major)\", notes = {7, 4, 0, 4, 7}},\n    },\n    tonalCenterMinor = {\n        {name = \"Do-Sol-Mi-Do (Minor)\", notes = {0, 7, 3, 0}},\n        {name = \"Do-Mi-Fa-Mi-Re-Do (Minor)\", notes = {0, 3, 5, 3, 2, 0}},\n        {name = \"Sol-Mi-Do-Mi-Sol (Minor)\", notes = {7, 3, 0, 3, 7}},\n    },\n    scales = {\n        {name = \"Sequential (All Notes)\", notes = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}},\n        {name = \"Blues Scale\", notes = {0, 3, 5, 6, 7, 10, 12}},\n        {name = \"Blues Scale Descending\", notes = {12, 10, 7, 6, 5, 3, 0}},\n        {name = \"Chromatic\", notes = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}},\n        {name = \"Whole Tone\", notes = {0, 2, 4, 6, 8, 10, 12}},\n        {name = \"Octatonic (Whole-Half)\", notes = {0, 2, 3, 5, 6, 8, 9, 11, 12}},\n        {name = \"Octatonic (Half-Whole)\", notes = {0, 1, 3, 4, 6, 7, 9, 10, 12}},\n        {name = \"Harmonic Minor\", notes = {0, 2, 3, 5, 7, 8, 11, 12}},\n        {name = \"Melodic Minor\", notes = {0, 2, 3, 5, 7, 9, 11, 12}},\n        {name = \"Major Bebop\", notes = {0, 2, 4, 5, 7, 8, 9, 11, 12}},\n        {name = \"Dominant Bebop\", notes = {0, 2, 4, 5, 7, 9, 10, 11, 12}},\n        {name = \"Hungarian Minor\", notes = {0, 2, 3, 6, 7, 8, 11, 12}},\n        {name = \"Double Harmonic\", notes = {0, 1, 4, 5, 7, 8, 11, 12}},\n        {name = \"Enigmatic\", notes = {0, 1, 4, 6, 8, 10, 11, 12}},\n        {name = \"Neapolitan Minor\", notes = {0, 1, 3, 5, 7, 8, 11, 12}},\n        {name = \"Augmented\", notes = {0, 3, 4, 7, 8, 11, 12}},\n    },\n    modes = {\n        {name = \"Dorian Mode\", notes = {0, 2, 3, 5, 7, 9, 10, 12}},\n        {name = \"Phrygian Mode\", notes = {0, 1, 3, 5, 7, 8, 10, 12}},\n        {name = \"Lydian Mode\", notes = {0, 2, 4, 6, 7, 9, 11, 12}},\n        {name = \"Mixolydian Mode\", notes = {0, 2, 4, 5, 7, 9, 10, 12}},\n        {name = \"Locrian Mode\", notes = {0, 1, 3, 5, 6, 8, 10, 12}},\n        {name = \"Aeolian Mode\", notes = {0, 2, 3, 5, 7, 8, 10, 12}},\n        {name = \"Lydian Dominant\", notes = {0, 2, 4, 6, 7, 9, 10, 12}},\n        {name = \"Phrygian Dominant\", notes = {0, 1, 4, 5, 7, 8, 10, 12}},\n        {name = \"Dorian b2\", notes = {0, 1, 3, 5, 7, 9, 10, 12}},\n        {name = \"Altered Scale\", notes = {0, 1, 3, 4, 6, 8, 10, 12}},\n        {name = \"Lydian Augmented\", notes = {0, 2, 4, 6, 8, 9, 11, 12}},\n        {name = \"Mixolydian b6\", notes = {0, 2, 4, 5, 7, 8, 10, 12}},\n    },\n    cadences = {\n        {name = \"Authentic Cadence (V-I)\", notes = {7, 11, 12}},\n        {name = \"Plagal Cadence (IV-I)\", notes = {5, 0}},\n        {name = \"Half Cadence (I-V)\", notes = {0, 7}},\n        {name = \"Deceptive Cadence (V-vi)\", notes = {7, 9}},\n        {name = \"I-IV-V-I\", notes = {0, 5, 7, 0}},\n        {name = \"I-vi-IV-V\", notes = {0, 9, 5, 7}},\n        {name = \"ii-V-I\", notes = {2, 7, 0}},\n        {name = \"I-V-vi-IV\", notes = {0, 7, 9, 5}},\n        {name = \"I-IV-I-V-I (Major)\", notes = {0, 5, 0, 7, 0}},\n        {name = \"ii-V-I-vi (Jazz)\", notes = {2, 7, 0, 9}},\n        {name = \"I-vi-ii-V (Jazz)\", notes = {0, 9, 2, 7}},\n        {name = \"iii-vi-ii-V-I\", notes = {4, 9, 2, 7, 0}},\n        {name = \"I-bVII-IV-I (Modal)\", notes = {0, 10, 5, 0}},\n        {name = \"I-V-IV-I (Mixolydian)\", notes = {0, 7, 5, 0}},\n    },\n    arpeggios = {\n        {name = \"Major Arpeggio\", notes = {0, 4, 7, 12}},\n        {name = \"Minor Arpeggio\", notes = {0, 3, 7, 12}},\n        {name = \"Dominant 7th Arpeggio\", notes = {0, 4, 7, 10, 12}},\n        {name = \"Diminished 7th Arpeggio\", notes = {0, 3, 6, 9, 12}},\n        {name = \"Half-Diminished 7th Arpeggio\", notes = {0, 3, 6, 10, 12}},\n        {name = \"Major 7th Arpeggio\", notes = {0, 4, 7, 11, 12}},\n        {name = \"Minor 7th Arpeggio\", notes = {0, 3, 7, 10, 12}},\n        {name = \"Augmented Arpeggio\", notes = {0, 4, 8, 12}},\n        {name = \"Diminished Arpeggio\", notes = {0, 3, 6, 12}},\n        {name = \"Major 9th Arpeggio\", notes = {0, 4, 7, 11, 12}},\n        {name = \"Major Arpeggio Descending\", notes = {12, 7, 4, 0}},\n        {name = \"Minor Arpeggio Descending\", notes = {12, 7, 3, 0}},\n        {name = \"Major Arpeggio Up & Down\", notes = {0, 4, 7, 12, 7, 4, 0}},\n        {name = \"Minor Arpeggio Up & Down\", notes = {0, 3, 7, 12, 7, 3, 0}},\n        {name = \"Dominant 7 Up & Down\", notes = {0, 4, 7, 10, 12, 10, 7, 4, 0}},\n        {name = \"Minor 7 Up & Down\", notes = {0, 3, 7, 10, 12, 10, 7, 3, 0}},\n        {name = \"Major 7 Extended\", notes = {0, 2, 4, 7, 11, 12}},\n        {name = \"Minor 7 Extended\", notes = {0, 2, 3, 7, 10, 12}},\n        {name = \"Dominant 9th Arpeggio\", notes = {0, 2, 4, 7, 10, 12}},\n        {name = \"Minor 9th Arpeggio\", notes = {0, 2, 3, 7, 10, 12}},\n    },\n    chords = {\n        {name = \"Augmented Triad\", notes = {0, 4, 8, 12}},\n        {name = \"Diminished Triad\", notes = {0, 3, 6, 12}},\n        {name = \"Major 7th Chord\", notes = {0, 4, 7, 11, 12}},\n        {name = \"Minor 7th Chord\", notes = {0, 3, 7, 10, 12}},\n        {name = \"Octaves\", notes = {0, 12, 0, 12}},\n        {name = \"Suspended 2nd\", notes = {0, 2, 7, 12}},\n        {name = \"Suspended 4th\", notes = {0, 5, 7, 12}},\n        {name = \"Add 9 Chord\", notes = {0, 2, 4, 7, 12}},\n        {name = \"Power Chord\", notes = {0, 7, 12}},\n        {name = \"Major 6th Chord\", notes = {0, 4, 7, 9, 12}},\n        {name = \"Minor 6th Chord\", notes = {0, 3, 7, 9, 12}},\n    }\n}\n\n-- Load patterns and convert to templates\nlocal function loadPatternTemplates()\n    -- Major Scales\n    for _, pattern in ipairs(patternData.majorScales) do\n        table.insert(templateLibrary.builtInTemplates.majorScales, patternToTemplate(pattern, \"major_scale\", 4))\n    end\n\n    -- Major Exercises\n    for _, pattern in ipairs(patternData.majorExercises) do\n        table.insert(templateLibrary.builtInTemplates.majorExercises, patternToTemplate(pattern, \"major_exercise\", 4))\n    end\n\n    -- Natural Minor Scales (Major-Equivalent Patterns)\n    for _, pattern in ipairs(patternData.naturalMinorScales) do\n        table.insert(templateLibrary.builtInTemplates.naturalMinor, patternToTemplate(pattern, \"natural_minor\", 4))\n    end\n\n    -- Pentatonics\n    for _, pattern in ipairs(patternData.pentatonics) do\n        table.insert(templateLibrary.builtInTemplates.pentatonics, patternToTemplate(pattern, \"pentatonic\", 4))\n    end\n\n    -- All Octaves (use octave 2 as suggested in patterns)\n    for _, pattern in ipairs(patternData.allOctaves) do\n        table.insert(templateLibrary.builtInTemplates.allOctaves, patternToTemplate(pattern, \"octave_training\", 2))\n    end\n\n    -- Tonal Center - combine all tonal center categories\n    for _, pattern in ipairs(patternData.tonalCenterAnchors) do\n        table.insert(templateLibrary.builtInTemplates.tonalCenter, patternToTemplate(pattern, \"tonal_anchor\", 4))\n    end\n    for _, pattern in ipairs(patternData.tonalCenterTonicEchoes) do\n        table.insert(templateLibrary.builtInTemplates.tonalCenter, patternToTemplate(pattern, \"tonic_echo\", 4))\n    end\n    for _, pattern in ipairs(patternData.tonalCenterMelodies) do\n        table.insert(templateLibrary.builtInTemplates.tonalCenter, patternToTemplate(pattern, \"tonal_melody\", 4))\n    end\n    for _, pattern in ipairs(patternData.tonalCenterMajor) do\n        table.insert(templateLibrary.builtInTemplates.tonalCenter, patternToTemplate(pattern, \"tonal_major\", 4))\n    end\n    for _, pattern in ipairs(patternData.tonalCenterMinor) do\n        table.insert(templateLibrary.builtInTemplates.tonalCenter, patternToTemplate(pattern, \"tonal_minor\", 4))\n    end\n\n    -- Scales & Modes - combine scales, modes, and cadences\n    for _, pattern in ipairs(patternData.scales) do\n        table.insert(templateLibrary.builtInTemplates.scalesAndModes, patternToTemplate(pattern, \"scale\", 4))\n    end\n    for _, pattern in ipairs(patternData.modes) do\n        table.insert(templateLibrary.builtInTemplates.scalesAndModes, patternToTemplate(pattern, \"mode\", 4))\n    end\n    for _, pattern in ipairs(patternData.cadences) do\n        table.insert(templateLibrary.builtInTemplates.scalesAndModes, patternToTemplate(pattern, \"cadence\", 4))\n    end\n\n    -- Arpeggios & Chords\n    for _, pattern in ipairs(patternData.arpeggios) do\n        table.insert(templateLibrary.builtInTemplates.arpeggiosAndChords, patternToTemplate(pattern, \"arpeggio\", 4))\n    end\n    for _, pattern in ipairs(patternData.chords) do\n        table.insert(templateLibrary.builtInTemplates.arpeggiosAndChords, patternToTemplate(pattern, \"chord\", 4))\n    end\n\n    local scaleSettings = {tempo=90, rootNote=0, droneNoteSelection=0, droneOctave=3,\n                           randomizeRootPlayback=false, randomizeOctavePlayback=false}\n    local scaleMutes = {false,true,true,false,false,false,false,false,\n                        false,false,false,false,false,false,false,false}\n    local scaleSequenceStates = {mutes=scaleMutes,\n        octaveTransposes={0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, activeIndex=1}\n\n    table.insert(templateLibrary.builtInTemplates.majorScales, {\n        id=\"text_major_scale\", name=\"Major Scale (Asc / Desc / Both)\",\n        description=\"Major scale: ascending, descending, and combined\",\n        difficulty=1, tags={\"major\",\"scale\",\"text\"},\n        texts={\n            \"Do Re Mi Fa Sol La Ti Do'\",\n            \"Do' Ti La Sol Fa Mi Re Do\",\n            \"Do Re Mi Fa Sol La Ti Do' Ti La Sol Fa Mi Re Do\",\n        },\n        settings=scaleSettings, sequenceStates=scaleSequenceStates,\n    })\n\n    table.insert(templateLibrary.builtInTemplates.naturalMinor, {\n        id=\"text_natural_minor_scale\", name=\"Natural Minor Scale (Asc / Desc / Both)\",\n        description=\"Natural minor scale: ascending, descending, and combined\",\n        difficulty=1, tags={\"natural minor\",\"scale\",\"text\"},\n        texts={\n            \"Do Re Me Fa Sol Le Te Do'\",\n            \"Do' Te Le Sol Fa Me Re Do\",\n            \"Do Re Me Fa Sol Le Te Do' Te Le Sol Fa Me Re Do\",\n        },\n        settings=scaleSettings, sequenceStates=scaleSequenceStates,\n    })\n\n    table.insert(templateLibrary.builtInTemplates.naturalMinor, {\n        id=\"text_harmonic_minor_scale\", name=\"Harmonic Minor Scale (Asc / Desc / Both)\",\n        description=\"Harmonic minor scale: ascending, descending, and combined\",\n        difficulty=2, tags={\"harmonic minor\",\"scale\",\"text\"},\n        texts={\n            \"Do Re Me Fa Sol Le Ti Do'\",\n            \"Do' Ti Le Sol Fa Me Re Do\",\n            \"Do Re Me Fa Sol Le Ti Do' Ti Le Sol Fa Me Re Do\",\n        },\n        settings=scaleSettings, sequenceStates=scaleSequenceStates,\n    })\n\n    table.insert(templateLibrary.builtInTemplates.naturalMinor, {\n        id=\"text_melodic_minor_scale\", name=\"Melodic Minor Scale (Asc / Desc / Both)\",\n        description=\"Melodic minor: raised 6th+7th ascending; natural minor descending\",\n        difficulty=2, tags={\"melodic minor\",\"scale\",\"text\"},\n        texts={\n            \"Do Re Me Fa Sol La Ti Do'\",\n            \"Do' Te Le Sol Fa Me Re Do\",\n            \"Do Re Me Fa Sol La Ti Do' Te Le Sol Fa Me Re Do\",\n        },\n        settings=scaleSettings, sequenceStates=scaleSequenceStates,\n    })\n\n    table.insert(templateLibrary.builtInTemplates.pentatonics, {\n        id=\"text_major_pentatonic_scale\", name=\"Major Pentatonic Scale (Asc / Desc / Both)\",\n        description=\"Major pentatonic: ascending, descending, and combined\",\n        difficulty=1, tags={\"pentatonic\",\"major\",\"scale\",\"text\"},\n        texts={\n            \"Do Re Mi Sol La Do'\",\n            \"Do' La Sol Mi Re Do\",\n            \"Do Re Mi Sol La Do' La Sol Mi Re Do\",\n        },\n        settings=scaleSettings, sequenceStates=scaleSequenceStates,\n    })\n\n    table.insert(templateLibrary.builtInTemplates.pentatonics, {\n        id=\"text_minor_pentatonic_scale\", name=\"Minor Pentatonic Scale (Asc / Desc / Both)\",\n        description=\"Minor pentatonic: ascending, descending, and combined\",\n        difficulty=1, tags={\"pentatonic\",\"minor\",\"scale\",\"text\"},\n        texts={\n            \"Do Me Fa Sol Te Do'\",\n            \"Do' Te Sol Fa Me Do\",\n            \"Do Me Fa Sol Te Do' Te Sol Fa Me Do\",\n        },\n        settings=scaleSettings, sequenceStates=scaleSequenceStates,\n    })\nend\n\n-- Initialize pattern templates\nloadPatternTemplates()\n\n-- Get all templates in a category\nfunction templateLibrary.getTemplatesByCategory(category)\n    -- SCALE CATEGORIES\n    if category == \"From Do\" then\n        local fromDoTemplates = {}\n        -- Include \"Major Scale Intervals from Do\" from intervals section\n        if templateLibrary.builtInTemplates.intervals then\n            for _, template in ipairs(templateLibrary.builtInTemplates.intervals) do\n                if template.id == \"intervals_major_scale_from_do\" then\n                    table.insert(fromDoTemplates, template)\n                end\n            end\n        end\n        -- Include Do-to-degree scale fragment templates\n        if templateLibrary.builtInTemplates.majorScales then\n            for _, template in ipairs(templateLibrary.builtInTemplates.majorScales) do\n                if template.name and template.name:match(\"^Do%-\") then\n                    table.insert(fromDoTemplates, template)\n                end\n            end\n        end\n        return fromDoTemplates\n\n    elseif category == \"Major Scales\" then\n        -- Exclude Do-to-degree templates (now in \"From Do\" category)\n        local majorTemplates = {}\n        if templateLibrary.builtInTemplates.majorScales then\n            for _, template in ipairs(templateLibrary.builtInTemplates.majorScales) do\n                if not (template.name and template.name:match(\"^Do%-\")) then\n                    table.insert(majorTemplates, template)\n                end\n            end\n        end\n        return majorTemplates\n\n    elseif category == \"Natural Minor\" then\n        return templateLibrary.builtInTemplates.naturalMinor or {}\n\n    elseif category == \"Pentatonic Scales\" then\n        return templateLibrary.builtInTemplates.pentatonics or {}\n\n    elseif category == \"Modes & Advanced\" then\n        -- Modes, cadences, and advanced scale patterns\n        return templateLibrary.builtInTemplates.scalesAndModes or {}\n\n    -- EXERCISE CATEGORIES\n    elseif category == \"Major Exercise Patterns\" then\n        local majorPatterns = {}\n        if templateLibrary.builtInTemplates.majorExercises then\n            for _, template in ipairs(templateLibrary.builtInTemplates.majorExercises) do\n                if template.patternGroup == \"scale_patterns\" then\n                    table.insert(majorPatterns, template)\n                end\n            end\n        end\n        return majorPatterns\n\n    elseif category == \"Major Exercise Intervals & Harmony\" then\n        local majorIntervals = {}\n        if templateLibrary.builtInTemplates.majorExercises then\n            for _, template in ipairs(templateLibrary.builtInTemplates.majorExercises) do\n                if template.patternGroup ~= \"scale_patterns\" then\n                    table.insert(majorIntervals, template)\n                end\n            end\n        end\n        return majorIntervals\n\n    elseif category == \"Major Exercises\" then\n        return templateLibrary.builtInTemplates.majorExercises or {}\n\n    elseif category == \"Range Exercises\" then\n        -- All octaves and tonal center exercises\n        local rangeTemplates = {}\n        if templateLibrary.builtInTemplates.allOctaves then\n            for _, template in ipairs(templateLibrary.builtInTemplates.allOctaves) do\n                table.insert(rangeTemplates, template)\n            end\n        end\n        if templateLibrary.builtInTemplates.tonalCenter then\n            for _, template in ipairs(templateLibrary.builtInTemplates.tonalCenter) do\n                table.insert(rangeTemplates, template)\n            end\n        end\n        return rangeTemplates\n\n    -- EAR TRAINING CATEGORIES\n    elseif category == \"Melody Ear Training\" then\n        -- Melodic dictation, call & response, scale degrees\n        local melodyEarTemplates = {}\n        if templateLibrary.builtInTemplates.earTraining then\n            for _, template in ipairs(templateLibrary.builtInTemplates.earTraining) do\n                if template.id == \"ear_melodic_dictation\" or\n                   template.id == \"ear_melodic_dictation_intermediate\" or\n                   template.id == \"ear_melodic_dictation_minor\" or\n                   template.id == \"ear_functional_phrases\" or\n                   template.id == \"ear_call_response\" or\n                   template.id == \"ear_scale_degree\" or\n                   template.id == \"ear_accapella_solfege_steps\" or\n                   template.id == \"ear_accapella_echo_phrase\" then\n                    table.insert(melodyEarTemplates, template)\n                end\n            end\n        end\n        return melodyEarTemplates\n\n    elseif category == \"Harmony Ear Training\" then\n        -- Chord quality recognition\n        local harmonyEarTemplates = {}\n        if templateLibrary.builtInTemplates.earTraining then\n            for _, template in ipairs(templateLibrary.builtInTemplates.earTraining) do\n                if template.id == \"ear_chord_quality\" or\n                   template.id == \"ear_chord_inversions\" or\n                   template.id == \"ear_cadence_recognition\" or\n                   template.id == \"ear_seventh_chord_quality\" or\n                   template.id == \"ear_harmonic_progressions\" then\n                    table.insert(harmonyEarTemplates, template)\n                end\n            end\n        end\n        return harmonyEarTemplates\n\n    elseif category == \"Rhythm Ear Training\" then\n        local rhythmEarTemplates = {}\n        if templateLibrary.builtInTemplates.earTraining then\n            for _, template in ipairs(templateLibrary.builtInTemplates.earTraining) do\n                if template.id == \"ear_rhythm_subdivision\" or\n                   template.id == \"ear_rhythm_syncopation\" then\n                    table.insert(rhythmEarTemplates, template)\n                end\n            end\n        end\n        return rhythmEarTemplates\n\n    elseif category == \"Functional Ear Training\" then\n        local functionalEarTemplates = {}\n        if templateLibrary.builtInTemplates.earTraining then\n            for _, template in ipairs(templateLibrary.builtInTemplates.earTraining) do\n                if template.id == \"ear_functional_phrases\" or\n                   template.id == \"ear_leading_tone_resolution\" or\n                   template.id == \"ear_cadence_recognition\" or\n                   template.id == \"ear_harmonic_progressions\" then\n                    table.insert(functionalEarTemplates, template)\n                end\n            end\n        end\n        return functionalEarTemplates\n\n    elseif category == \"A Cappella Ear Training\" then\n        local accapellaEarTemplates = {}\n        if templateLibrary.builtInTemplates.earTraining then\n            for _, template in ipairs(templateLibrary.builtInTemplates.earTraining) do\n                if template.id == \"ear_accapella_solfege_steps\" or\n                   template.id == \"ear_accapella_interval_pairs\" or\n                   template.id == \"ear_accapella_echo_phrase\" then\n                    table.insert(accapellaEarTemplates, template)\n                end\n            end\n        end\n        return accapellaEarTemplates\n\n    -- HARMONY CATEGORIES\n    elseif category == \"Arpeggios\" then\n        -- Arpeggio templates from arpeggiosAndChords\n        local arpeggioTemplates = {}\n        if templateLibrary.builtInTemplates.arpeggiosAndChords then\n            for _, template in ipairs(templateLibrary.builtInTemplates.arpeggiosAndChords) do\n                -- Filter for arpeggios based on pattern type\n                if template.patternType == \"arpeggio\" then\n                    table.insert(arpeggioTemplates, template)\n                end\n            end\n        end\n        return arpeggioTemplates\n\n    elseif category == \"Chords\" then\n        -- Chord templates from arpeggiosAndChords\n        local chordTemplates = {}\n        if templateLibrary.builtInTemplates.arpeggiosAndChords then\n            for _, template in ipairs(templateLibrary.builtInTemplates.arpeggiosAndChords) do\n                -- Filter for chords based on pattern type\n                if template.patternType == \"chord\" then\n                    table.insert(chordTemplates, template)\n                end\n            end\n        end\n        return chordTemplates\n\n    -- COMPOSITION CATEGORIES\n    elseif category == \"Song Forms\" then\n        -- Verse-chorus structure\n        local songFormTemplates = {}\n        if templateLibrary.builtInTemplates.composition then\n            for _, template in ipairs(templateLibrary.builtInTemplates.composition) do\n                if template.id == \"comp_verse_chorus\" then\n                    table.insert(songFormTemplates, template)\n                end\n            end\n        end\n        return songFormTemplates\n\n    elseif category == \"Jazz & Blues\" then\n        -- Blues 12-bar and jazz ii-V-I\n        local jazzBluesTemplates = {}\n        if templateLibrary.builtInTemplates.composition then\n            for _, template in ipairs(templateLibrary.builtInTemplates.composition) do\n                if template.id == \"comp_blues_12bar\" or\n                   template.id == \"comp_jazz_ii_v_i\" then\n                    table.insert(jazzBluesTemplates, template)\n                end\n            end\n        end\n        return jazzBluesTemplates\n\n    elseif category == \"User\" then\n        -- User templates will be loaded from storage\n        return {}\n    end\n\n    return {}\nend\n\n-- Get template by ID\nfunction templateLibrary.getTemplateById(templateId)\n    for _, category in pairs(templateLibrary.builtInTemplates) do\n        for _, template in ipairs(category) do\n            if template.id == templateId then\n                return template\n            end\n        end\n    end\n    return nil\nend\n\n-- Load template into state\nfunction templateLibrary.loadTemplate(state, template, core, options)\n    if not template then\n        return false\n    end\n    local stepsOnly = options and options.stepsOnly\n\n    if type(template.musicxml) == \"string\" and template.musicxml ~= \"\" then\n        local MusicXML = resolveModule(\"musicxml\")\n        local saved\n        if stepsOnly then\n            saved = {\n                tempo = state.tempo, defaultTempo = state.defaultTempo,\n                stepBeats = state.stepBeats,\n                meterNumerator = state.meterNumerator, meterDenominator = state.meterDenominator,\n                rootNote = state.rootNote, keyNote = state.keyNote, keyOctave = state.keyOctave,\n                droneNoteSelection = state.droneNoteSelection, droneOctave = state.droneOctave,\n            }\n        end\n        local imported, importError = MusicXML.importStateFromString(state, core, template.musicxml)\n        if not imported then\n            print(\"Template MusicXML import failed: \" .. tostring(importError))\n            return false\n        end\n        if stepsOnly and saved then\n            for k, v in pairs(saved) do state[k] = v end\n            core.setTimeSignature(state, saved.meterNumerator, saved.meterDenominator)\n            state.stepDuration = (60 / (saved.tempo or 120)) * 1000 * (saved.stepBeats or 1)\n        end\n        return true\n    end\n\n    local resolvedSequences = template.sequences\n    local resolvedLengths   = template.sequenceLengths\n    local defaultOctave     = (template.settings and template.settings.defaultOctave) or 4\n\n    if type(template.text) == \"string\" and template.text ~= \"\" then\n        local seq = parseTemplateText(template.text, defaultOctave)\n        resolvedSequences = {[1] = seq}\n        resolvedLengths   = {[1] = #seq}\n    elseif type(template.texts) == \"table\" then\n        resolvedSequences = {}\n        resolvedLengths   = {}\n        for i, txt in ipairs(template.texts) do\n            if type(txt) == \"string\" and txt ~= \"\" then\n                local seq = parseTemplateText(txt, defaultOctave)\n                resolvedSequences[i] = seq\n                resolvedLengths[i]   = #seq\n            end\n        end\n    end\n\n    local function inferSequenceLength(sequenceData, maxSteps)\n        if type(sequenceData) ~= \"table\" then\n            return 0\n        end\n        local inferred = 0\n        for stepIndex = 1, maxSteps do\n            if sequenceData[stepIndex] ~= nil then\n                inferred = stepIndex\n            end\n        end\n        return inferred\n    end\n\n    local function getTemplateSequenceCount()\n        local highestIndex = 0\n        if type(resolvedSequences) == \"table\" then\n            for sequenceIndex = 1, core.maxSequences do\n                if resolvedSequences[sequenceIndex] ~= nil then\n                    highestIndex = sequenceIndex\n                end\n            end\n        end\n        if type(resolvedLengths) == \"table\" then\n            for sequenceIndex = 1, core.maxSequences do\n                if (resolvedLengths[sequenceIndex] or 0) > 0 then\n                    highestIndex = math.max(highestIndex, sequenceIndex)\n                end\n            end\n        end\n        if highestIndex < 1 then\n            highestIndex = 1\n        end\n        return math.min(highestIndex, core.maxSequences)\n    end\n\n    local templateSequenceCount = getTemplateSequenceCount()\n\n    -- Reset sequence state and make the loaded sequence count match the template\n    for i = 1, core.maxSequences do\n        if i <= templateSequenceCount then\n            state.sequences[i] = {}\n        else\n            state.sequences[i] = nil\n        end\n        state.sequenceLengths[i] = 0\n        state.sequenceMutes[i] = false\n        state.sequenceOctaveTranspose[i] = 0\n    end\n\n    -- Reset playback state\n    state.currentStep = 1\n    state.playbackPosition = 1\n\n    local function copyStepData(stepData)\n        if not stepData then\n            return nil\n        end\n        if stepData.notes then\n            local notes = {}\n            for noteIndex, noteData in ipairs(stepData.notes) do\n                notes[noteIndex] = {note = noteData.note, octave = noteData.octave}\n            end\n            return {notes = notes, length = stepData.length, lyric = stepData.lyric,\n                    gate = stepData.gate, muted = stepData.muted, paragraphEnd = stepData.paragraphEnd}\n        end\n        return {note = stepData.note, octave = stepData.octave, length = stepData.length,\n                lyric = stepData.lyric, gate = stepData.gate, muted = stepData.muted,\n                paragraphEnd = stepData.paragraphEnd}\n    end\n\n    local templateSequences = resolvedSequences or {}\n\n    -- Load sequences from template (only within the template's sequence count)\n    for i = 1, templateSequenceCount do\n        local sequenceData = templateSequences[i]\n        local templateLength = 0\n        if type(resolvedLengths) == \"table\" then\n            templateLength = resolvedLengths[i] or 0\n        end\n        local inferredLength = inferSequenceLength(sequenceData, core.maxSteps)\n        templateLength = math.max(templateLength, inferredLength)\n        templateLength = math.min(templateLength, core.maxSteps)\n\n        -- Only load if the template has a non-zero length for this sequence\n        if sequenceData and templateLength > 0 then\n            state.sequences[i] = {}\n            for stepIndex = 1, templateLength do\n                state.sequences[i][stepIndex] = copyStepData(sequenceData[stepIndex])\n            end\n            state.sequenceLengths[i] = templateLength\n        else\n            -- Ensure blank sequences are completely empty\n            state.sequences[i] = {}\n            state.sequenceLengths[i] = 0\n        end\n    end\n\n    -- Load settings\n    if not stepsOnly and template.settings then\n        local settings = template.settings\n        local templateTempo = settings.tempo\n        if type(settings.stepBeats) == \"number\" and settings.stepBeats > 0 then\n            core.setStepBeats(state, settings.stepBeats)\n        end\n        if type(templateTempo) == \"number\" then\n            templateTempo = math.min(990, math.max(1, templateTempo))\n            state.defaultTempo = templateTempo\n            core.setTempo(state, templateTempo)\n        end\n        if type(settings.rootNote) == \"number\" then\n            state.rootNote = math.min(11, math.max(0, settings.rootNote))\n        end\n        if type(settings.droneNoteSelection) == \"number\" then\n            state.droneNoteSelection = math.min(12, math.max(0, settings.droneNoteSelection))\n            if settings.droneEnabled == nil then\n                state.droneEnabled = settings.droneNoteSelection >= 0\n            end\n        end\n        if settings.droneEnabled ~= nil then\n            state.droneEnabled = settings.droneEnabled == true\n        end\n        if type(settings.droneOctave) == \"number\" then\n            state.droneOctave = math.min(7, math.max(2, settings.droneOctave))\n        end\n        if settings.randomizeRootPlayback ~= nil then\n            state.randomizeRootPlayback = settings.randomizeRootPlayback\n        end\n        if settings.randomizeOctavePlayback ~= nil then\n            state.randomizeOctavePlayback = settings.randomizeOctavePlayback\n        end\n        if settings.showHandsDuringPlayback ~= nil then\n            state.showHandsDuringPlayback = settings.showHandsDuringPlayback\n        end\n        if settings.soundPreviewEnabled ~= nil then\n            state.soundPreviewEnabled = settings.soundPreviewEnabled\n        end\n        if settings.soundPreviewOnNavigation ~= nil then\n            state.soundPreviewOnNavigation = settings.soundPreviewOnNavigation\n        end\n        if settings.acapellaMode ~= nil then\n            state.acapellaMode = settings.acapellaMode\n        end\n        if settings.darkMode ~= nil then\n            state.darkMode = settings.darkMode\n        end\n        if settings.playKeyBeforeSteps ~= nil then\n            state.playKeyBeforeSteps = settings.playKeyBeforeSteps\n        end\n        if type(settings.keyLeadInBeats) == \"number\" then\n            state.keyLeadInBeats = math.min(4, math.max(1, settings.keyLeadInBeats))\n        end\n        if type(settings.keyNote) == \"number\" then\n            state.keyNote = math.min(11, math.max(0, settings.keyNote))\n        end\n        if type(settings.keyOctave) == \"number\" then\n            state.keyOctave = math.min(7, math.max(2, settings.keyOctave))\n        end\n        if settings.hideNoteNamesDuringPlayback ~= nil then\n            state.hideNoteNamesDuringPlayback = settings.hideNoteNamesDuringPlayback\n        end\n        if type(settings.playbackStopSeconds) == \"number\" then\n            state.playbackStopSeconds = math.max(0, settings.playbackStopSeconds)\n        end\n    end\n\n    -- Load sequence states\n    if not stepsOnly and template.sequenceStates then\n        for i = 1, core.maxSequences do\n            state.sequenceMutes[i] = template.sequenceStates.mutes[i] or false\n            state.sequenceOctaveTranspose[i] = template.sequenceStates.octaveTransposes[i] or 0\n        end\n        state.activeSequenceIndex = template.sequenceStates.activeIndex or 1\n    end\n\n    if state.activeSequenceIndex < 1 or state.activeSequenceIndex > templateSequenceCount then\n        state.activeSequenceIndex = 1\n    end\n\n    -- IMPORTANT: Update state.sequence and state.sequenceLength BEFORE calling setActiveSequence\n    -- Otherwise setActiveSequence's syncActiveSequenceState will overwrite with old values\n    local activeIdx = state.activeSequenceIndex\n    state.sequence = state.sequences[activeIdx]\n    state.sequenceLength = state.sequenceLengths[activeIdx] or 0\n\n    -- Set active sequence (this will sync properly now)\n    core.setActiveSequence(state, state.activeSequenceIndex)\n\n    return true\nend\n\nfunction templateLibrary.convertTemplateToMusicXML(template, coreModule, musicXMLModule)\n    if not template then\n        return nil, \"Template is required\"\n    end\n\n    if type(template.musicxml) == \"string\" and template.musicxml ~= \"\" then\n        return template.musicxml\n    end\n\n    local coreRef = coreModule or resolveModule(\"sequencer_core\")\n    local musicRef = musicXMLModule or resolveModule(\"musicxml\")\n    local state = coreRef.createState()\n    local loaded = templateLibrary.loadTemplate(state, template, coreRef)\n    if not loaded then\n        return nil, \"Template could not be loaded\"\n    end\n\n    return musicRef.exportStateToString(state, coreRef)\nend\n\nfunction templateLibrary.refactorBuiltInTemplatesAsMusicXML(coreModule, musicXMLModule)\n    local convertedCount = 0\n    for _, categoryTemplates in pairs(templateLibrary.builtInTemplates) do\n        for _, template in ipairs(categoryTemplates) do\n            if type(template.musicxml) ~= \"string\" and type(template.sequences) == \"table\" then\n                local xml, err = templateLibrary.convertTemplateToMusicXML(template, coreModule, musicXMLModule)\n                if xml then\n                    template.musicxml = xml\n                    template.sequences = nil\n                    template.sequenceLengths = nil\n                    template.settings = nil\n                    template.sequenceStates = nil\n                    convertedCount = convertedCount + 1\n                else\n                    print(\"Template conversion skipped for '\" .. tostring(template.id) .. \"': \" .. tostring(err))\n                end\n            end\n        end\n    end\n    return convertedCount\nend\n\n-- Create template from current state\nfunction templateLibrary.createTemplateFromState(state, metadata)\n    local template = {\n        id = metadata.id or \"user_template_\" .. os.time(),\n        name = metadata.name or \"Untitled Template\",\n        description = metadata.description or \"\",\n        difficulty = metadata.difficulty or 1,\n        tags = metadata.tags or {},\n        sequences = {},\n        sequenceLengths = {},\n        settings = {\n            tempo = state.tempo,\n            rootNote = state.rootNote,\n            droneEnabled = state.droneEnabled,\n            droneNoteSelection = state.droneNoteSelection,\n            droneOctave = state.droneOctave,\n            randomizeRootPlayback = state.randomizeRootPlayback,\n            randomizeOctavePlayback = state.randomizeOctavePlayback,\n            stepBeats = state.stepBeats,\n            showHandsDuringPlayback = state.showHandsDuringPlayback,\n            soundPreviewEnabled = state.soundPreviewEnabled,\n            soundPreviewOnNavigation = state.soundPreviewOnNavigation,\n            acapellaMode = state.acapellaMode,\n            darkMode = state.darkMode,\n            playKeyBeforeSteps = state.playKeyBeforeSteps,\n            keyLeadInBeats = state.keyLeadInBeats,\n            keyNote = state.keyNote,\n            keyOctave = state.keyOctave,\n            hideNoteNamesDuringPlayback = state.hideNoteNamesDuringPlayback,\n            playbackStopSeconds = state.playbackStopSeconds\n        },\n        sequenceStates = {\n            mutes = {},\n            octaveTransposes = {},\n            activeIndex = state.activeSequenceIndex\n        }\n    }\n\n    local function copyStepData(stepData)\n        if not stepData then\n            return nil\n        end\n        if stepData.notes then\n            local notes = {}\n            for noteIndex, noteData in ipairs(stepData.notes) do\n                notes[noteIndex] = {note = noteData.note, octave = noteData.octave}\n            end\n            return {notes = notes, length = stepData.length, lyric = stepData.lyric,\n                    gate = stepData.gate, muted = stepData.muted, paragraphEnd = stepData.paragraphEnd}\n        end\n        return {note = stepData.note, octave = stepData.octave, length = stepData.length,\n                lyric = stepData.lyric, gate = stepData.gate, muted = stepData.muted,\n                paragraphEnd = stepData.paragraphEnd}\n    end\n\n    local function copySequence(sequenceData, maxSteps)\n        if type(sequenceData) ~= \"table\" then\n            return {}\n        end\n        local copy = {}\n        for stepIndex = 1, maxSteps do\n            if sequenceData[stepIndex] ~= nil then\n                copy[stepIndex] = copyStepData(sequenceData[stepIndex])\n            end\n        end\n        return copy\n    end\n\n    -- Save sequences (use constant 16 to avoid circular dependency with sequencer_core)\n    local maxSequences = 16\n    local maxSteps = 64\n    for i = 1, maxSequences do\n        if state.sequences[i] then\n            template.sequences[i] = copySequence(state.sequences[i], maxSteps)\n        end\n        template.sequenceLengths[i] = state.sequenceLengths[i] or 0\n        template.sequenceStates.mutes[i] = state.sequenceMutes[i] or false\n        template.sequenceStates.octaveTransposes[i] = state.sequenceOctaveTranspose[i] or 0\n    end\n\n    return template\nend\n\n-- Count templates in a category\nfunction templateLibrary.countTemplatesInCategory(category)\n    local templates = templateLibrary.getTemplatesByCategory(category)\n    return #templates\nend\n\nreturn templateLibrary\n","text_edit.lua":"local M = {}\n\nlocal WORD_PAT = \"[%w_'%-]\"\n\n-- Jump to the start of the previous word (Option+Left / Option+Backspace).\n-- 1-based: cp is cursor position (1..#b+1), returns new position (1..#b+1).\nfunction M.wordJumpLeft(b, cp)\n    local p = cp - 1\n    while p >= 1 and not b:sub(p,p):match(WORD_PAT) do p = p - 1 end\n    while p >  1 and     b:sub(p-1,p-1):match(WORD_PAT) do p = p - 1 end\n    return math.max(1, p)\nend\n\n-- Jump past the end of the next word (Option+Right / Option+Delete).\nfunction M.wordJumpRight(b, cp)\n    local p = cp\n    while p <= #b and not b:sub(p,p):match(WORD_PAT) do p = p + 1 end\n    while p <= #b and     b:sub(p,p):match(WORD_PAT) do p = p + 1 end\n    return p\nend\n\n-- Inclusive start of the word containing position p (for double-click selection).\nfunction M.wordStart(buf, p)\n    local i = math.min(p, #buf)\n    while i > 1 and buf:sub(i-1, i-1):match(WORD_PAT) do i = i - 1 end\n    if buf:sub(i, i):match(WORD_PAT) then return i end\n    local j = p - 1\n    while j > 1 and buf:sub(j-1, j-1):match(WORD_PAT) do j = j - 1 end\n    if j >= 1 and buf:sub(j, j):match(WORD_PAT) then return j end\n    return p\nend\n\n-- Exclusive end of the word starting at position p (for double-click selection).\nfunction M.wordEnd(buf, p)\n    local i = math.min(p, #buf)\n    if not buf:sub(i, i):match(WORD_PAT) then\n        while i <= #buf and not buf:sub(i, i):match(WORD_PAT) do i = i + 1 end\n    end\n    while i <= #buf and buf:sub(i, i):match(WORD_PAT) do i = i + 1 end\n    return i\nend\n\n-- Start of the line containing position cp (after preceding \\n, or 1).\nfunction M.lineStart(b, cp)\n    if cp <= 1 then return 1 end\n    local i = math.min(cp - 1, #b)\n    while i >= 1 and b:sub(i, i) ~= \"\\n\" do i = i - 1 end\n    return i + 1\nend\n\n-- Exclusive end of the line containing position cp (position of \\n, or #b+1).\nfunction M.lineEnd(b, cp)\n    local nl = b:find(\"\\n\", cp)\n    return nl or (#b + 1)\nend\n\nreturn M\n","timer_adapter.lua":"local TimerAdapter = {}\n\nfunction TimerAdapter.new(implementation)\n    local self = {}\n\n    function self.newRepeating(durationMilliseconds, callback)\n        return implementation.newRepeating(durationMilliseconds, callback)\n    end\n\n    function self.newOneShot(durationMilliseconds, callback)\n        return implementation.newOneShot(durationMilliseconds, callback)\n    end\n\n    function self.update()\n        if implementation.update then\n            implementation.update()\n        end\n    end\n\n    return self\nend\n\nreturn TimerAdapter\n","ui.lua":"local ui = {}\n\nlocal APP_VERSION = \"1.0.0\"\n\nlocal _touchMode = false\nlocal _safeAreaTop = 0\nlocal _safeAreaBottom = 0\n\nlocal NATURAL_MINOR_SOLFEGE_OVERRIDES = {\n    [3] = \"Me\",\n    [8] = \"Le\",\n    [10] = \"Te\"\n}\n\nlocal function getTemplateSolfegeLabel(template, noteIndex, solfegeNotes)\n    if template and template.patternType == \"natural_minor\" and NATURAL_MINOR_SOLFEGE_OVERRIDES[noteIndex] then\n        return NATURAL_MINOR_SOLFEGE_OVERRIDES[noteIndex]\n    end\n\n    return solfegeNotes[noteIndex + 1] or \"--\"\nend\n\nlocal function getTemplatePreview(template, solfegeNotes, maxSteps)\n    if not template or not template.sequences then\n        return nil\n    end\n\n    local sequenceIndex = 1\n    if template.sequenceStates and template.sequenceStates.activeIndex then\n        sequenceIndex = template.sequenceStates.activeIndex\n    end\n\n    local sequence = template.sequences[sequenceIndex] or template.sequences[1]\n    if not sequence then\n        return nil\n    end\n\n    local previewNotes = {}\n    local lengthFromTemplate = nil\n    if template.sequenceLengths and template.sequenceLengths[sequenceIndex] and template.sequenceLengths[sequenceIndex] > 0 then\n        lengthFromTemplate = template.sequenceLengths[sequenceIndex]\n    end\n    local steps = math.min(lengthFromTemplate or #sequence, maxSteps or 8)\n    for stepIndex = 1, steps do\n        local stepData = sequence[stepIndex]\n        local noteIndex = nil\n        if stepData then\n            if stepData.note ~= nil then\n                noteIndex = stepData.note\n            elseif stepData.notes and stepData.notes[1] and stepData.notes[1].note ~= nil then\n                noteIndex = stepData.notes[1].note\n            end\n        end\n\n        if noteIndex ~= nil then\n            table.insert(previewNotes, getTemplateSolfegeLabel(template, noteIndex, solfegeNotes))\n        else\n            table.insert(previewNotes, \"--\")\n        end\n    end\n\n    if #previewNotes == 0 then\n        return nil\n    end\n\n    return table.concat(previewNotes, \" \")\nend\n\nlocal CHROMATIC_NOTE_NAMES = {\n    [0]=\"C\", [1]=\"C#\", [2]=\"D\", [3]=\"D#\", [4]=\"E\", [5]=\"F\",\n    [6]=\"F#\", [7]=\"G\", [8]=\"G#\", [9]=\"A\", [10]=\"A#\", [11]=\"B\", [12]=\"C\"\n}\n\nlocal SHAPE_NOTE_SYMBOLS = {\n    [\"Do\"] = \"▲\",\n    [\"Re\"] = \"◗\",\n    [\"Mi\"] = \"■\",\n    [\"Fa\"] = \"◆\",\n    [\"Sol\"] = \"●\",\n    [\"La\"] = \"◯\",\n    [\"Ti\"] = \"▱\"\n}\n\nlocal SHAPE_NOTE_BASE_MAP = {\n    [\"Do\"] = {base = \"Do\", altered = false},\n    [\"Di\"] = {base = \"Do\", altered = true},\n    [\"Re\"] = {base = \"Re\", altered = false},\n    [\"Ri\"] = {base = \"Re\", altered = true},\n    [\"Mi\"] = {base = \"Mi\", altered = false},\n    [\"Fa\"] = {base = \"Fa\", altered = false},\n    [\"Fi\"] = {base = \"Fa\", altered = true},\n    [\"Sol\"] = {base = \"Sol\", altered = false},\n    [\"Si\"] = {base = \"Sol\", altered = true},\n    [\"La\"] = {base = \"La\", altered = false},\n    [\"Li\"] = {base = \"La\", altered = true},\n    [\"Ti\"] = {base = \"Ti\", altered = false}\n}\n\nlocal MAC_TRANSPORT_BUTTONS = {\n    home      = {x = 0,   width = 46, height = 26, label = \"Home\"},\n    edit      = {x = 0,   width = 44, height = 26, label = \"Edit\"},\n    project   = {x = 0,   width = 44, height = 26, label = \"New\"},\n    file      = {x = 0,   width = 40, height = 26, label = \"File\"},\n    mic       = {x = 164, width = 76, height = 26, label = \"Mic\"},\n    keynote   = {x = 0,   width = 46, height = 26, label = \"Do\"},\n\n    view      = {x = 0,   width = 42, height = 26, label = \"View\"},\n    rec       = {x = 0,   width = 34, height = 26, label = \"Rec\"},\n    loop      = {x = 0,   width = 32, height = 26, label = \"↻\"},\n    beginning = {x = 0,   width = 32, height = 26, label = \"|◀\"},\n    play     = {x = 264, width = 32, height = 26, label = \"▶\"},\n    stop     = {x = 324, width = 32, height = 26, label = \"■\"},\n    input    = {x = 0,   width = 52, height = 26, label = \"Input\"},\n    volume   = {x = 0,   width = 80, height = 26, label = \"Vol\"},\n    setkey   = {x = 0,   width = 40, height = 26, label = \"Set\"},\n    mute     = {x = 0,   width = 40, height = 26, label = \"Mute\"},\n}\n\nlocal MASTER_VOLUME_SLIDER = {\n    trackInsetX = 32,\n    trackWidth = 40,\n    trackHeight = 4,\n    knobSize = 8\n}\n\nlocal DISPLAY_OPTIONS_DROPDOWN_ITEMS = {\n    { key = \"panels\",   label = \"Panels\",   submenu = \"panels\" },\n    { key = \"mode\",     label = \"Mode\",     submenu = \"mode\" },\n    { key = \"notation\", label = \"Notation\", submenu = \"notation\" },\n    { key = \"lyrics\",   label = \"Lyrics\",   submenu = \"lyrics\" },\n}\nlocal DISPLAY_SUBMENUS = {\n    mode = {\n        { key = \"composeMode\",    label = \"Compose\" },\n        { key = \"singMode\",       label = \"Sing\" },\n        { key = \"lyricsOnlyMode\", label = \"Lyrics Only\" },\n        { key = \"stepsOnlyMode\",  label = \"Steps Only\" },\n        { key = \"stepsLyricsMode\",label = \"Steps + Lyrics\" },\n    },\n    panels = {\n        { key = \"darkMode\",            label = \"Dark Mode\" },\n        { key = \"sidebarOpen\",         label = \"Parts\" },\n        { key = \"showToolsRow\",        label = \"Tools\" },\n        { key = \"showPlaybackRow\",     label = \"Playback Row\" },\n        { key = \"showBarsBeatsRow\",    label = \"Bars+Beats\" },\n        { key = \"showAddStepButton\",   label = \"Add Step\" },\n        { key = \"showMicRow\",          label = \"Microphone\" },\n        { key = \"showSolfegeTextInput\",label = \"Text Input\" },\n        { key = \"showSolfegeButtons\",  label = \"Solfege Buttons\" },\n    },\n    notation = {\n        { key = \"hideSteps\",    label = \"Timeline\" },\n        { key = \"showBarLines\", label = \"Bar Lines\" },\n        { key = \"showNoteNames\",label = \"Note Names\" },\n        { key = \"useShapeNotes\",      label = \"Shape Notes\" },\n        { key = \"showRomanNumerals\",  label = \"Roman Numerals\" },\n        { key = \"showNoteLengths\",    label = \"Note Lengths\" },\n        { key = \"showOctaveNumbers\",  label = \"Octave Numbers\" },\n    },\n    lyrics = {\n        { key = \"showLyrics\",          label = \"Lyrics\" },\n        { key = \"showSolfegeLyrics\",   label = \"Solfege Lyrics\" },\n        { key = \"lyricNotesPanelOpen\", label = \"Lyric Notes\" },\n        { key = \"lyricsWindow\",        label = \"Lyrics Window\" },\n        { key = \"solfegeShowBreaks\",   label = \"Show Breaks\" },\n    },\n}\n\nlocal INPUT_SOURCES_DROPDOWN_ITEMS = {\n    { key = \"midi_in\",  label = \"MIDI In\" },\n    { key = \"mic_input\", label = \"Microphone\" },\n    { key = \"gamepad\", label = \"Gamepad\" },\n    { key = \"webcam\",  label = \"Webcam\" }\n}\n\nlocal EDIT_DROPDOWN_ITEMS = {\n    { key = \"undo\",               label = \"Undo\" },\n    { key = \"redo\",               label = \"Redo\" },\n    { key = \"copy\",               label = \"Copy\" },\n    { key = \"cut\",                label = \"Cut\" },\n    { key = \"paste\",              label = \"Paste\" },\n    { key = \"remove_empty_steps\", label = \"Remove Empty Steps\" },\n    { key = \"spell_check\",        label = \"Spell Check\" },\n    { key = \"templates\",          label = \"Insert Template\" },\n}\n\nlocal FILE_DROPDOWN_ITEMS = {\n    { key = \"new_project\", label = \"New\" },\n    { key = \"save\",        label = \"Save\" },\n    { key = \"open_recent\", label = \"Open Recent\", submenu = \"open_recent\" },\n    { key = \"revert\",      label = \"Revert\",      submenu = \"revert\" },\n    { key = \"import\",      label = \"Import\",      submenu = \"import\" },\n    { key = \"export\",      label = \"Export\",      submenu = \"export\" },\n}\nlocal FILE_SUBMENUS = {\n    import = {\n        { key = \"import_musicxml\", label = \"MusicXML\" },\n        { key = \"import_docx\",     label = \"DOCX Lyrics\" },\n    },\n    export = {\n        { key = \"export_musicxml\",      label = \"MusicXML\" },\n        { key = \"export_musicxml_as\",   label = \"MusicXML As\" },\n        { key = \"export_wav\",           label = \"WAV Audio\" },\n        { key = \"export_mp3\",           label = \"MP3 Audio\" },\n        { key = \"export_docx\",          label = \"DOCX Lyrics\" },\n        { key = \"export_text_lyrics\",   label = \"Text Lyrics\" },\n        { key = \"export_lyric_creator\", label = \"CT-S1000V Lyrics\" },\n    },\n}\n\nlocal SOLFEGE_SCALE_DROPDOWN_ITEMS = {\n    { key = \"major\", label = \"Major\" },\n    { key = \"natural_minor\", label = \"Natural Minor\" },\n    { key = \"harmonic_minor\", label = \"Harmonic Minor\" },\n    { key = \"melodic_minor\", label = \"Melodic Minor\" },\n    { key = \"all\", label = \"All Syllables\" },\n    { key = \"custom\", label = \"Custom...\" },\n}\n\nlocal SCALE_STEP_OPTIONS_BY_MODE = {\n    major = {11, 9, 7, 5, 4, 2, 0},\n    natural_minor = {10, 8, 7, 5, 3, 2, 0},\n    harmonic_minor = {11, 8, 7, 5, 3, 2, 0},\n    melodic_minor = {11, 9, 7, 5, 3, 2, 0},\n    all = {11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0},\n    custom = {11, 9, 7, 5, 4, 2, 0},\n}\n\nlocal function getScaleStepOptions(scaleMode)\n    return SCALE_STEP_OPTIONS_BY_MODE[scaleMode or \"major\"] or SCALE_STEP_OPTIONS_BY_MODE.major\nend\n\nlocal KEYNOTE_OPTIONS_BY_MODE = {\n    major = {0, 2, 4, 5, 7, 9, 11, 12},\n    natural_minor = {0, 2, 3, 5, 7, 8, 10, 12},\n    harmonic_minor = {0, 2, 3, 5, 7, 8, 11, 12},\n    melodic_minor = {0, 2, 3, 5, 7, 9, 11, 12},\n    all = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},\n    custom = {0, 2, 4, 5, 7, 9, 11, 12},\n}\n\nfunction ui.setCustomScaleStepOptions(notesArr)\n    -- notesArr: ascending absolute semitones e.g. {0,2,4,5,7,9,11,12}\n    KEYNOTE_OPTIONS_BY_MODE.custom = notesArr\n    local steps = {}\n    for i = #notesArr - 1, 1, -1 do\n        steps[#steps + 1] = notesArr[i]\n    end\n    SCALE_STEP_OPTIONS_BY_MODE.custom = steps\nend\n\nlocal function getKeynoteOptions(scaleMode)\n    return KEYNOTE_OPTIONS_BY_MODE[scaleMode or \"major\"] or KEYNOTE_OPTIONS_BY_MODE.major\nend\n\nlocal MAC_HEADER_BUTTON_ORDER = {}\nlocal MAC_TOP_ROW_BUTTON_ORDER = {\"file\", \"edit\", \"view\", \"keynote\", \"setkey\", \"input\"}\nlocal DROPDOWN_VERTICAL_PADDING = 8\nlocal DROPDOWN_ITEM_HEIGHT = 24\nlocal DROPDOWN_HEADER_HEIGHT = 18\nlocal LIST_ITEM_HEIGHT = 24   -- content height per list row\nlocal LIST_ITEM_GAP    = 4    -- visual gap between rows\nlocal LIST_ITEM_STRIDE = LIST_ITEM_HEIGHT + LIST_ITEM_GAP  -- = 28\nlocal LYRIC_NOTES_PANEL_WIDTH = 124\nlocal LYRIC_NOTES_PANEL_MARGIN = 6\nlocal LYRIC_NOTES_PANEL_TOP = 80\n\nfunction ui.getMacTransportButtons()\n    return MAC_TRANSPORT_BUTTONS\nend\n\nfunction ui.setTouchMode(enabled)\n    _touchMode = enabled\n    if enabled then\n        for _, btn in pairs(MAC_TRANSPORT_BUTTONS) do\n            btn.height = 44\n            btn.width = math.max(btn.width, 48)\n        end\n        MAC_TRANSPORT_BUTTONS.volume.width = 120\n        MASTER_VOLUME_SLIDER.trackHeight = 6\n        MASTER_VOLUME_SLIDER.knobSize = 14\n        MASTER_VOLUME_SLIDER.trackWidth = 52\n        MASTER_VOLUME_SLIDER.trackInsetX = 36\n        DROPDOWN_ITEM_HEIGHT = 48\n        DROPDOWN_HEADER_HEIGHT = 34\n        DROPDOWN_VERTICAL_PADDING = 12\n        LIST_ITEM_HEIGHT = 44\n        LIST_ITEM_GAP = 8\n        LIST_ITEM_STRIDE = LIST_ITEM_HEIGHT + LIST_ITEM_GAP\n        SOLFEGE_SYLLABLE_ROW_H = 56\n    end\nend\n\nfunction ui.setSafeArea(top, bottom)\n    _safeAreaTop = top or 0\n    _safeAreaBottom = bottom or 0\nend\n\n-- Last-computed step grid layout, updated each render frame.\nlocal lastStepGrid = nil\n\n-- Scratch tables reused across frames to reduce per-frame allocations / GC pressure.\nlocal _scratchStepCol  = {}\nlocal _scratchStepRow  = {}\nlocal _scratchRowLen   = {}\nlocal _scratchRowStart = {}\nlocal _scratchXOffsets = {}\nlocal _scratchGrid     = {}\nlocal _scratchGridBounds = {}\n\nlocal function clearTable(t)\n    for k in pairs(t) do t[k] = nil end\nend\n\n-- Cached font heights per style: populated once at frame start, avoids\n-- repeated getTextSize(\"X\") calls (~10+/frame).\nlocal _fontH = { normal = 10, bold = 10, small = 8 }\n\n-- Returns the grid layout from the most recent render, or nil if not yet rendered.\n-- Fields: startX, startY, stepWidth, stepHeight, horizontalGap, verticalGap,\n--         stepsPerRow, rowsToShow\nfunction ui.getStepGridLayout()\n    return lastStepGrid\nend\n\nlocal function getDropdownTextYOffset(gfx, itemHeight)\n    return math.max(0, math.floor((itemHeight - _fontH.normal) / 2))\nend\n\nlocal function getShapeNoteInfo(noteLabel)\n    if type(noteLabel) ~= \"string\" then\n        return nil\n    end\n\n    local normalized = noteLabel:gsub(\"'\", \"\")\n    return SHAPE_NOTE_BASE_MAP[normalized]\nend\n\nlocal function getShapeNoteSymbol(noteLabel)\n    local info = getShapeNoteInfo(noteLabel)\n    if not info then\n        return noteLabel\n    end\n\n    local symbol = SHAPE_NOTE_SYMBOLS[info.base]\n    if not symbol then\n        return noteLabel\n    end\n\n    if info.altered then\n        return symbol .. \"+\"\n    end\n\n    return symbol\nend\n\nlocal function drawShapeNoteGlyph(gfx, noteLabel, x, y, width, height)\n    local info = getShapeNoteInfo(noteLabel)\n    if not info then\n        return false\n    end\n\n    local function fillTriangle(topX, topY, leftX, leftY, rightX, rightY)\n        local triHeight = leftY - topY\n        if triHeight <= 0 then\n            return\n        end\n\n        for i = 0, triHeight do\n            local t = i / triHeight\n            local yLine = topY + i\n            local xStart = math.floor(topX + (leftX - topX) * t)\n            local xEnd = math.floor(topX + (rightX - topX) * t)\n            if xEnd < xStart then\n                xStart, xEnd = xEnd, xStart\n            end\n            gfx.drawLine(xStart, yLine, xEnd, yLine)\n        end\n    end\n\n    local function fillDiamond(centerX, centerY, radius)\n        for dy = -radius, radius do\n            local halfWidth = radius - math.abs(dy)\n            gfx.drawLine(centerX - halfWidth, centerY + dy, centerX + halfWidth, centerY + dy)\n        end\n    end\n\n    local function fillHorizontalOval(centerX, centerY, radiusX, radiusY)\n        radiusX = math.max(2, radiusX)\n        radiusY = math.max(1, radiusY)\n\n        for dy = -radiusY, radiusY do\n            local ratio = 1 - ((dy * dy) / (radiusY * radiusY))\n            local halfWidth = math.floor(radiusX * math.sqrt(math.max(0, ratio)))\n            gfx.drawLine(centerX - halfWidth, centerY + dy, centerX + halfWidth, centerY + dy)\n        end\n    end\n\n    local function fillRightHalfCircle(centerX, centerY, radius)\n        for dy = -radius, radius do\n            local ratio = 1 - ((dy * dy) / (radius * radius))\n            local halfWidth = math.floor(radius * math.sqrt(math.max(0, ratio)))\n            gfx.drawLine(centerX, centerY + dy, centerX + halfWidth, centerY + dy)\n        end\n    end\n\n    local function drawRightHalfCircleOutline(centerX, centerY, radius)\n        local prevY, prevRightX = nil, nil\n        for dy = -radius, radius do\n            local ratio = 1 - ((dy * dy) / (radius * radius))\n            local halfWidth = math.floor(radius * math.sqrt(math.max(0, ratio)))\n            local yPos = centerY + dy\n            local rightX = centerX + halfWidth\n            if prevY ~= nil then\n                gfx.drawLine(prevRightX, prevY, rightX, yPos)\n            end\n            prevY = yPos\n            prevRightX = rightX\n        end\n        gfx.drawLine(centerX, centerY - radius, centerX, centerY + radius)\n    end\n\n    local function fillParallelogram(x, y, width, height, slant)\n        for row = 0, height - 1 do\n            local t = row / math.max(1, height - 1)\n            local xOffset = math.floor(slant * (1 - t))\n            gfx.drawLine(x + xOffset, y + row, x + xOffset + width - 1, y + row)\n        end\n    end\n\n    local shape = info.base\n    local inset = math.max(1, math.floor(math.min(width, height) * 0.08))\n    local boxX = x + inset\n    local boxY = y + inset\n    local boxWidth = math.max(6, width - (inset * 2))\n    local boxHeight = math.max(6, height - (inset * 2))\n    local centerX = math.floor(boxX + boxWidth / 2)\n    local centerY = math.floor(boxY + boxHeight / 2)\n    local radius = math.max(2, math.floor(math.min(boxWidth, boxHeight) / 2) - 1)\n    local horizontalRadius = math.max(2, math.floor(boxWidth / 2) - 1)\n    local verticalRadius = math.max(2, math.floor(boxHeight / 2) - 1)\n\n    local function drawSharpMarker(markerX, markerY, markerScale)\n        local stemHeight = math.max(4, markerScale + 2)\n        local stemLeft = markerX - 2\n        local stemRight = markerX + 1\n\n        gfx.drawLine(stemLeft, markerY - 1, stemLeft, markerY + stemHeight)\n        gfx.drawLine(stemRight, markerY - 1, stemRight, markerY + stemHeight)\n        gfx.drawLine(markerX - 3, markerY + 1, markerX + 2, markerY)\n        gfx.drawLine(markerX - 3, markerY + stemHeight - 1, markerX + 2, markerY + stemHeight - 2)\n    end\n\n    if shape == \"Do\" then\n        local topY = centerY - verticalRadius\n        local bottomY = centerY + verticalRadius\n        local halfBase = math.max(3, horizontalRadius)\n        fillTriangle(centerX, topY, centerX - halfBase, bottomY, centerX + halfBase, bottomY)\n        gfx.drawLine(centerX, topY, centerX - halfBase, bottomY)\n        gfx.drawLine(centerX, topY, centerX + halfBase, bottomY)\n        gfx.drawLine(centerX - halfBase, bottomY, centerX + halfBase, bottomY)\n    elseif shape == \"Re\" then\n        local circleRadius = math.max(3, math.min(horizontalRadius, verticalRadius))\n        local offsetCenterX = centerX - math.floor(circleRadius * 0.35)\n        fillRightHalfCircle(offsetCenterX, centerY, circleRadius)\n        drawRightHalfCircleOutline(offsetCenterX, centerY, circleRadius)\n    elseif shape == \"Mi\" then\n        local side = math.max(4, math.min(boxWidth, boxHeight) - 1)\n        local squareX = centerX - math.floor(side / 2)\n        local squareY = centerY - math.floor(side / 2)\n        gfx.fillRect(squareX, squareY, side, side)\n        gfx.drawRect(squareX, squareY, side, side)\n    elseif shape == \"Fa\" then\n        local diamondRadius = math.max(3, math.min(horizontalRadius, verticalRadius))\n        fillDiamond(centerX, centerY, diamondRadius)\n        gfx.drawLine(centerX, centerY - diamondRadius, centerX + diamondRadius, centerY)\n        gfx.drawLine(centerX + diamondRadius, centerY, centerX, centerY + diamondRadius)\n        gfx.drawLine(centerX, centerY + diamondRadius, centerX - diamondRadius, centerY)\n        gfx.drawLine(centerX - diamondRadius, centerY, centerX, centerY - diamondRadius)\n    elseif shape == \"Sol\" then\n        local circleRadius = math.max(2, math.min(horizontalRadius, verticalRadius))\n        fillHorizontalOval(centerX, centerY, circleRadius, circleRadius)\n        gfx.drawCircle(centerX, centerY, circleRadius)\n    elseif shape == \"La\" then\n        local outerRadius = math.max(3, math.min(horizontalRadius, verticalRadius) - 1)\n        local innerRadius = math.max(1, math.floor(outerRadius * 0.5))\n        for dy = -outerRadius, outerRadius do\n            local outerHalf = math.floor(outerRadius * math.sqrt(math.max(0, 1 - (dy * dy) / (outerRadius * outerRadius))))\n            local innerSq = 1 - (dy * dy) / (innerRadius * innerRadius)\n            local innerHalf = (innerSq > 0) and math.floor(innerRadius * math.sqrt(innerSq)) or 0\n            if math.abs(dy) >= innerRadius or innerHalf == 0 then\n                gfx.drawLine(centerX - outerHalf, centerY + dy, centerX + outerHalf, centerY + dy)\n            else\n                gfx.drawLine(centerX - outerHalf, centerY + dy, centerX - innerHalf, centerY + dy)\n                gfx.drawLine(centerX + innerHalf, centerY + dy, centerX + outerHalf, centerY + dy)\n            end\n        end\n        gfx.drawCircle(centerX, centerY, outerRadius)\n        gfx.drawCircle(centerX, centerY, innerRadius)\n    elseif shape == \"Ti\" then\n        local shapeHeight = math.max(5, math.floor(verticalRadius * 1.5))\n        local shapeWidth = math.max(7, math.floor(horizontalRadius * 2.1))\n        local slant = math.max(2, math.floor(shapeHeight * 0.45))\n        local shapeX = centerX - math.floor((shapeWidth + slant - 1) / 2)\n        local shapeY = centerY - math.floor(shapeHeight / 2)\n\n        fillParallelogram(shapeX, shapeY, shapeWidth, shapeHeight, slant)\n        gfx.drawLine(shapeX + slant, shapeY, shapeX + slant + shapeWidth - 1, shapeY)\n        gfx.drawLine(shapeX, shapeY + shapeHeight - 1, shapeX + shapeWidth - 1, shapeY + shapeHeight - 1)\n        gfx.drawLine(shapeX + slant, shapeY, shapeX, shapeY + shapeHeight - 1)\n        gfx.drawLine(shapeX + slant + shapeWidth - 1, shapeY, shapeX + shapeWidth - 1, shapeY + shapeHeight - 1)\n    else\n        return false\n    end\n\n    if info.altered then\n        local markerScale = math.max(2, math.floor(radius * 0.75))\n        local markerX = math.min(x + width - 3, centerX + horizontalRadius + 1)\n        local markerY = math.max(y + 1, centerY - verticalRadius + 1)\n        drawSharpMarker(markerX, markerY, markerScale)\n    end\n\n    return true\nend\n\nlocal function drawStepNote(gfx, ctx, noteData, x, y, stepWidth, textY, useShapeNotes, shapeGlyphHeight, isMacDesktop, showNoteNames, rootNote)\n    local noteLabel = ctx.solfegeNotes[noteData.note + 1] or \"--\"\n    local absoluteNote = (noteData.note + (rootNote or 0)) % 12\n    local displayLabel = showNoteNames and (CHROMATIC_NOTE_NAMES[absoluteNote] or \"--\") or noteLabel\n    local glyphInset = isMacDesktop and 6 or 7\n    local glyphWidth = stepWidth - (glyphInset * 2)\n    local glyphHeight = shapeGlyphHeight or 13\n    if useShapeNotes and drawShapeNoteGlyph(gfx, noteLabel, x + glyphInset, y + textY - 1, glyphWidth, glyphHeight) then\n        local labelWidth = gfx.getTextSize(displayLabel)\n        local labelX = math.floor(x + (stepWidth - labelWidth) / 2)\n        local labelY = y + textY + glyphHeight - 1\n        gfx.drawText(displayLabel, labelX, labelY)\n        return\n    end\n\n    local noteText = (useShapeNotes and not showNoteNames) and getShapeNoteSymbol(noteLabel) or displayLabel\n    local textWidth = gfx.getTextSize(noteText)\n    local textX = math.floor(x + (stepWidth - textWidth) / 2)\n    gfx.drawText(noteText, textX, y + textY)\nend\n\nlocal function drawStepLyric(gfx, lyric, x, y, stepWidth)\n    if type(lyric) ~= \"string\" or lyric == \"\" then\n        return\n    end\n    local display = lyric:gsub(\"\\n+$\", \"\")\n    if display ~= \"\" then gfx.drawText(display, x + 1, y) end\nend\n\nlocal function drawEditableStepLyric(gfx, state, stepIndex, x, y, stepWidth)\n    local lyric = state.lyricInputBuffer or \"\"\n    local cursorPos = state.lyricInputCursor\n    local cursorAtEnd = (cursorPos == nil)\n    if cursorPos == nil then cursorPos = #lyric end\n\n    local selLo, selHi = nil, nil\n    if state.lyricSelAnchor ~= nil and state.lyricSelFocus ~= nil and state.lyricSelAnchor ~= state.lyricSelFocus then\n        selLo = math.min(state.lyricSelAnchor, state.lyricSelFocus)\n        selHi = math.max(state.lyricSelAnchor, state.lyricSelFocus)\n    end\n    local _, lyricTextH = gfx.getTextSize(\"|\")\n    lyricTextH = (lyricTextH and lyricTextH > 0) and lyricTextH or 16\n    if selLo then\n        local x1 = x + 1 + gfx.getTextSize(lyric:sub(1, selLo))\n        local x2 = x + 1 + gfx.getTextSize(lyric:sub(1, selHi))\n        if x2 > x1 then\n            gfx.setColor(100, 160, 230, 120)\n            gfx.fillRect(x1, y, x2 - x1, lyricTextH)\n            gfx.setColor(0, 0, 0, 255)\n        end\n    end\n\n    gfx.setColor(20, 30, 60, 255)\n    if lyric ~= \"\" then gfx.drawText(lyric, x + 1, y) end\n    -- Cursor: solid when mid-text (cursor not at end), blinking when at end\n    local cursorVisible = not cursorAtEnd or (math.floor(os.clock() * 1.5) % 2 == 0)\n    if cursorVisible then\n        local cursorX = x + 1 + (cursorPos > 0 and gfx.getTextSize(lyric:sub(1, cursorPos)) or 0)\n        gfx.setColor(30, 30, 80, 255)\n        gfx.fillRect(cursorX, y, _touchMode and 2 or 1, lyricTextH)\n    end\n    state._lyricStepInputBounds = {\n        stepIndex = stepIndex,\n        x = x + 1,\n        y = y,\n        w = math.max(1, stepWidth - 2),\n        h = lyricTextH,\n        text = lyric,\n        font = \"small\",  -- matches the gfx.setFont(\"small\") set by the caller\n    }\nend\n\nlocal function getShapeStaffNoteY(noteData, y, stepHeight, topPadding, bottomPadding)\n    local note = (noteData and noteData.note) or 0\n    local octave = (noteData and noteData.octave) or 4\n    local pitchPosition = (octave * 12) + note\n    local minPitchPosition = (2 * 12)\n    local maxPitchPosition = (7 * 12) + 11\n    local normalized = (pitchPosition - minPitchPosition) / (maxPitchPosition - minPitchPosition)\n    normalized = math.max(0, math.min(1, normalized))\n\n    topPadding = topPadding or 3\n    bottomPadding = bottomPadding or 3\n    local drawableHeight = math.max(4, stepHeight - topPadding - bottomPadding)\n    return y + topPadding + math.floor((1 - normalized) * drawableHeight)\nend\n\n-- Layout constants (defaults; updated dynamically in ui.render from gfx adapter)\nlocal SCREEN_W = 400\nlocal SCREEN_H = 300\n\n-- Portrait detection helper (updated each frame alongside SCREEN_W/H)\nlocal function isPortraitLayout()\n    return SCREEN_W < SCREEN_H\nend\n-- Dynamic list item width: fills available screen instead of hardcoded 380\nlocal function listItemWidth()\n    return math.max(120, SCREEN_W - 20)\nend\n\n-- Sidebar constants\nlocal SIDEBAR_WIDTH = 60\nlocal SIDEBAR_ENTRY_HEIGHT = 15\nlocal SIDEBAR_START_Y = 30\n\n-- Solfege text input bar — base values (actual height is dynamic)\nlocal SOLFEGE_LINE_H   = 14   -- height per text line (overridden dynamically from state.solfegeTextFontSize)\nlocal SOLFEGE_PAD      = 7    -- padding inside the outer bar\nlocal SOLFEGE_BOX_PAD  = 4    -- padding inside the white box\nlocal SOLFEGE_TEXT_INSET = 4  -- extra left inset so the cursor never hugs the box border\nlocal MAX_SOLFEGE_VISIBLE_LINES = 20  -- max lines shown in bottom mode; scroll for more\nlocal SOLFEGE_SIDEBAR_W = 130        -- width when docked to left or right\nlocal SOLFEGE_BTN_ROW_H = 0         -- height of the dock-button strip above the white box\nlocal SOLFEGE_SYLLABLE_ROW_H = 34   -- height of tappable solfege syllable buttons row\nlocal function solfegeInputAreaH(lineCount)\n    lineCount = math.max(1, lineCount or 1)\n    return SOLFEGE_PAD * 2 + SOLFEGE_BOX_PAD * 2 + lineCount * SOLFEGE_LINE_H\nend\nlocal SOLFEGE_TEXT_SIZE_PRESETS = {\n    {value = 1, label = \"9\",  name = \"9\",  lineH = 10, font = \"small\"},\n    {value = 2, label = \"11\", name = \"11\", lineH = 12, font = \"small\"},\n    {value = 3, label = \"13\", name = \"13\", lineH = 13, font = \"normal\"},\n    {value = 4, label = \"15\", name = \"15\", lineH = 16, font = \"bold\"},\n    {value = 5, label = \"17\", name = \"17\", lineH = 18, font = \"bold\"},\n}\n\nlocal SOLFEGE_TEMPLATE_CATEGORIES = {\n    { name = \"Scales\", templates = {\n        {name = \"Major \\xe2\\x86\\x91\",           text = \"Do Re Mi Fa Sol La Ti Do\"},\n        {name = \"Major \\xe2\\x86\\x93\",           text = \"Do Ti La Sol Fa Mi Re Do\"},\n        {name = \"Natural Minor \\xe2\\x86\\x91\",   text = \"Do Re Me Fa Sol Le Te Do\"},\n        {name = \"Natural Minor \\xe2\\x86\\x93\",   text = \"Do Te Le Sol Fa Me Re Do\"},\n        {name = \"Harmonic Minor \\xe2\\x86\\x91\",  text = \"Do Re Me Fa Sol Le Ti Do\"},\n        {name = \"Melodic Minor \\xe2\\x86\\x91\",   text = \"Do Re Me Fa Sol La Ti Do\"},\n        {name = \"Dorian \\xe2\\x86\\x91\",          text = \"Do Re Me Fa Sol La Te Do\"},\n        {name = \"Mixolydian \\xe2\\x86\\x91\",      text = \"Do Re Mi Fa Sol La Te Do\"},\n        {name = \"Lydian \\xe2\\x86\\x91\",          text = \"Do Re Mi Fi Sol La Ti Do\"},\n        {name = \"Phrygian \\xe2\\x86\\x91\",        text = \"Do Ra Me Fa Sol Le Te Do\"},\n        {name = \"Major Pentatonic\",             text = \"Do Re Mi Sol La Do\"},\n        {name = \"Minor Pentatonic\",             text = \"Do Me Fa Sol Te Do\"},\n        {name = \"Blues\",                        text = \"Do Me Fa Fi Sol Te Do\"},\n    }},\n    { name = \"Arpeggios\", templates = {\n        {name = \"Major Triad\",    text = \"Do Mi Sol Do\"},\n        {name = \"Minor Triad\",    text = \"Do Me Sol Do\"},\n        {name = \"Diminished\",     text = \"Do Me Se Do\"},\n        {name = \"Augmented\",      text = \"Do Mi Si Do\"},\n        {name = \"Dom 7\",          text = \"Do Mi Sol Te\"},\n        {name = \"Major 7\",        text = \"Do Mi Sol Ti\"},\n        {name = \"Minor 7\",        text = \"Do Me Sol Te\"},\n        {name = \"Triad Up-Down\",  text = \"Do Mi Sol Mi Do\"},\n        {name = \"Dom7 Up-Down\",   text = \"Do Mi Sol Te Sol Mi Do\"},\n    }},\n    { name = \"Patterns\", templates = {\n        {name = \"Scale Degrees\",  text = \"Do Re Mi Fa Sol La Ti\"},\n        {name = \"3rds Up\",        text = \"Do Mi Re Fa Mi Sol Fa La Sol Ti La Do\"},\n        {name = \"3rds Down\",      text = \"Do La Ti Sol La Fa Sol Mi Fa Re Mi Do\"},\n        {name = \"4ths Up\",        text = \"Do Fa Ti Mi La Re Sol Do\"},\n        {name = \"Tonic-Dom\",      text = \"Do Sol Do Sol Do\"},\n        {name = \"Neighbor\",       text = \"Do Ti Do Re Do Re Mi Re Mi Fa\"},\n    }},\n}\n\nlocal function solfegeTextSizePreset(state)\n    local sz = state and (state.solfegeTextFontSize or 2) or 2\n    for _, preset in ipairs(SOLFEGE_TEXT_SIZE_PRESETS) do\n        if preset.value == sz then return preset end\n    end\n    return SOLFEGE_TEXT_SIZE_PRESETS[2]\nend\n\nlocal function solfegeLineHeight(state)\n    return solfegeTextSizePreset(state).lineH\nend\nlocal function solfegeTextFontStyle(state)\n    return solfegeTextSizePreset(state).font\nend\n\nlocal function drawSolfegeSizePicker(gfx, state, x, y, w, h)\n    local preset = solfegeTextSizePreset(state)\n    gfx.setColor(200, 200, 200, 230)\n    gfx.fillRect(x, y, w, h)\n    gfx.setColor(130, 130, 130, 255)\n    gfx.drawRect(x, y, w, h)\n    gfx.setColor(50, 50, 50, 255)\n    local label = preset.label\n    local lw, lh = gfx.getTextSize(label)\n    gfx.drawText(label, x + math.floor((w - lw) / 2), y + math.floor((h - lh) / 2))\n    state._solfegeSizeBtn = {x = x, y = y, w = w, h = h, label = preset.label, name = preset.name, value = preset.value}\nend\n\nlocal function drawSolfegeSizeMenu(gfx, state)\n    if not state._solfegeSizeMenuOpen then\n        state._solfegeSizeMenuItems = nil\n        return\n    end\n    local btn = state._solfegeSizeBtn\n    if not btn then\n        state._solfegeSizeMenuItems = nil\n        return\n    end\n    local itemH = btn.h\n    local menuW = 38\n    local menuX = btn.x\n    local menuY = btn.y + btn.h + 2\n    local menuH = #SOLFEGE_TEXT_SIZE_PRESETS * itemH + 2\n    gfx.setColor(245, 245, 245, 245)\n    gfx.fillRect(menuX, menuY, menuW, menuH)\n    gfx.setColor(110, 110, 110, 255)\n    gfx.drawRect(menuX, menuY, menuW, menuH)\n    local items = {}\n    for i, preset in ipairs(SOLFEGE_TEXT_SIZE_PRESETS) do\n        local iy = menuY + 1 + (i - 1) * itemH\n        local active = preset.value == (state.solfegeTextFontSize or 2)\n        if active then\n            gfx.setColor(80, 110, 150, 230)\n            gfx.fillRect(menuX + 1, iy, menuW - 2, itemH)\n            gfx.setColor(255, 255, 255, 255)\n        else\n            gfx.setColor(50, 50, 50, 255)\n        end\n        gfx.drawText(preset.name, menuX + 5, iy + math.floor((itemH - 8) / 2))\n        items[#items + 1] = {x = menuX, y = iy, w = menuW, h = itemH, value = preset.value, label = preset.label, name = preset.name}\n    end\n    state._solfegeSizeMenuItems = items\nend\n\nlocal function drawSolfegeAutocomplete(gfx, state)\n    local ac = state._solfegeAutocomplete\n    local acX = state._solfegeCursorScreenX\n    local acY = state._solfegeCursorScreenY\n    if not ac or not acX or not acY or not state.solfegeInputActive then\n        state._solfegeAcBtns = nil\n        return\n    end\n    local itemH = SOLFEGE_LINE_H + 2\n    gfx.setFont(\"normal\")\n    local maxW = 0\n    for _, item in ipairs(ac.items) do\n        local label = type(item) == \"table\" and item.label or item\n        local tw = gfx.getTextSize(label)\n        if tw > maxW then maxW = tw end\n    end\n    maxW = math.min(maxW, SCREEN_W - 20)\n    local popW = maxW + 14\n    local popH = #ac.items * itemH + 4\n    local popX = math.min(acX, SCREEN_W - popW - 4)\n    if popX < 4 then popX = 4 end\n    local popY = acY + 2\n    if popY + popH > SCREEN_H - 4 then popY = acY - SOLFEGE_LINE_H - popH - 2 end\n    gfx.setColor(0, 0, 0, 28)\n    gfx.fillRect(popX + 2, popY + 2, popW, popH)\n    gfx.setColor(250, 250, 252, 248)\n    gfx.fillRect(popX, popY, popW, popH)\n    gfx.setColor(140, 140, 150, 255)\n    gfx.drawRect(popX, popY, popW, popH)\n    local mx, my = state.mouseX or -1, state.mouseY or -1\n    local btns = {}\n    for i, item in ipairs(ac.items) do\n        local iy = popY + 2 + (i - 1) * itemH\n        local isSel = (ac.sel or 1) == i\n        local isHov = mx >= popX and mx < popX + popW and my >= iy and my < iy + itemH\n        if isSel then\n            gfx.setColor(55, 100, 175, 220)\n            gfx.fillRect(popX + 1, iy, popW - 2, itemH)\n            gfx.setDrawMode(\"fillWhite\")\n        elseif isHov then\n            gfx.setColor(210, 222, 242, 180)\n            gfx.fillRect(popX + 1, iy, popW - 2, itemH)\n            gfx.setDrawMode(\"copy\")\n        else\n            gfx.setDrawMode(\"copy\")\n        end\n        local displayText = type(item) == \"table\" and item.label or item\n        gfx.drawText(displayText, popX + 5, iy + 1)\n        gfx.setDrawMode(\"copy\")\n        btns[i] = {x = popX, y = iy, w = popW, h = itemH}\n    end\n    gfx.setFont(\"bold\")\n    state._solfegeAcBtns = btns\nend\n\nlocal function drawSolfegeTemplatePicker(gfx, state)\n    local tp = state._solfegeTemplatePicker\n    local acX = state._solfegeCursorScreenX\n    local acY = state._solfegeCursorScreenY\n    if not tp or not acX or not acY or not state.solfegeInputActive then\n        state._solfegeTPBtns = nil\n        return\n    end\n    local maxVisible = 8\n    local itemH = SOLFEGE_LINE_H + 2\n    gfx.setFont(\"normal\")\n    -- Check if any items are predicted (matchScore > 0) so we can reserve space for the indicator\n    local hasPredicted = false\n    for _, item in ipairs(tp.items) do\n        if (item.matchScore or 0) > 0 then hasPredicted = true; break end\n    end\n    local starW = hasPredicted and (gfx.getTextSize(\"\\xe2\\x86\\x92\") + 4) or 0  -- \"→\" indicator width\n    local maxW = 0\n    for _, item in ipairs(tp.items) do\n        local tw = gfx.getTextSize(item.label)\n        if tw > maxW then maxW = tw end\n    end\n    maxW = math.min(maxW, SCREEN_W - 20)\n    local popW = maxW + 14 + starW\n    local visCount = math.min(#tp.items, maxVisible)\n    local popH = visCount * itemH + 4\n    local popX = math.min(acX, SCREEN_W - popW - 4)\n    if popX < 4 then popX = 4 end\n    local popY = acY + 2\n    if popY + popH > SCREEN_H - 4 then popY = acY - SOLFEGE_LINE_H - popH - 2 end\n    gfx.setColor(0, 0, 0, 28)\n    gfx.fillRect(popX + 2, popY + 2, popW, popH)\n    gfx.setColor(250, 250, 252, 248)\n    gfx.fillRect(popX, popY, popW, popH)\n    gfx.setColor(140, 140, 150, 255)\n    gfx.drawRect(popX, popY, popW, popH)\n    local mx, my = state.mouseX or -1, state.mouseY or -1\n    local btns = {}\n    local scrollTop = tp.scrollTop or 0\n    for vi = 1, visCount do\n        local i = scrollTop + vi\n        if i > #tp.items then break end\n        local item = tp.items[i]\n        local iy = popY + 2 + (vi - 1) * itemH\n        local isSel = (tp.sel or 1) == i\n        local isHov = mx >= popX and mx < popX + popW and my >= iy and my < iy + itemH\n        local isPredicted = (item.matchScore or 0) > 0\n        if isSel then\n            if isPredicted then\n                gfx.setColor(55, 140, 110, 220)\n            else\n                gfx.setColor(55, 100, 175, 220)\n            end\n            gfx.fillRect(popX + 1, iy, popW - 2, itemH)\n            gfx.setDrawMode(\"fillWhite\")\n        elseif isPredicted then\n            gfx.setColor(220, 245, 235, 200)\n            gfx.fillRect(popX + 1, iy, popW - 2, itemH)\n            gfx.setDrawMode(\"copy\")\n        elseif isHov then\n            gfx.setColor(210, 222, 242, 180)\n            gfx.fillRect(popX + 1, iy, popW - 2, itemH)\n            gfx.setDrawMode(\"copy\")\n        else\n            gfx.setDrawMode(\"copy\")\n        end\n        local textX = popX + 5\n        if hasPredicted then\n            if isPredicted then\n                if not isSel then gfx.setColor(60, 160, 110, 255) end\n                gfx.drawText(\"\\xe2\\x86\\x92\", textX, iy + 1)  -- \"→\"\n            end\n            textX = textX + starW\n        end\n        if not isSel and not isPredicted then gfx.setColor(50, 50, 50, 255) end\n        if isSel then gfx.setDrawMode(\"fillWhite\") end\n        gfx.drawText(item.label, textX, iy + 1)\n        gfx.setDrawMode(\"copy\")\n        btns[vi] = {x = popX, y = iy, w = popW, h = itemH}\n    end\n    if #tp.items > maxVisible then\n        local sbX = popX + popW - 4\n        local sbY = popY + 2\n        local sbH = popH - 4\n        local thumbH = math.max(4, math.floor(sbH * maxVisible / #tp.items))\n        local thumbY = sbY + math.floor((sbH - thumbH) * scrollTop / math.max(1, #tp.items - maxVisible))\n        gfx.setColor(180, 180, 190, 200)\n        gfx.fillRect(sbX, thumbY, 3, thumbH)\n    end\n    gfx.setFont(\"bold\")\n    state._solfegeTPBtns = btns\nend\n\n-- Single source of truth for all sections shown in the Text Options panel.\n-- Both the floating panel (drawSolfegeAllOptions) and the full window\n-- (ui.renderTextOptionsWindow) call this so additions only need to happen here.\nlocal function buildSolfegeOptionsSections(state)\n    local scaleSyls = state._acScaleSyls or {}\n    local ascText  = table.concat(scaleSyls, \" \")\n    local descSyls = {}\n    for i = #scaleSyls, 1, -1 do descSyls[#descSyls + 1] = scaleSyls[i] end\n    local descText = table.concat(descSyls, \" \")\n    local arcText  = ascText .. \" \" .. descText\n\n    return {\n        {header = \"\\xe2\\x99\\xa9 Scale Syllables\", items = scaleSyls},\n        {header = \"\\xe2\\x86\\x95 Runs\", items = {\n            {label = \"\\xe2\\x86\\x91  ascending\",                      text = ascText},\n            {label = \"\\xe2\\x86\\x93  descending\",                     text = descText},\n            {label = \"\\xe2\\x86\\x91\\xe2\\x86\\x93  arc (up \\xe2\\x86\\x92 down)\", text = arcText},\n        }},\n        {header = \"\\xe2\\x8c\\xa8 Keywords\", items = {\n            {label = \"Scale:major / natural_minor\",        text = \"Scale:\"},\n            {label = \"Scale:harmonic / melodic_minor\",     text = \"Scale:harmonic_minor\"},\n            {label = \"BPM:120\",                            text = \"BPM:\"},\n            {label = \"Meter:4/4\",                          text = \"Meter:\"},\n            {label = \"Octave:+1  /  Octave:-1\",           text = \"Octave:\"},\n            {label = \"Loop:on  /  Loop:off\",               text = \"Loop:\"},\n            {label = \"T:+2 / T:-3  \\xe2\\x80\\x94 transpose semitones\", text = \"T:\"},\n        }},\n        {header = \"\\xe2\\x99\\xac Live Coding\", items = {\n            {label = \"(Do Re Mi)x4   \\xe2\\x80\\x94 repeat pattern\",    text = \"(\"},\n            {label = \"<Do Mi Sol>    \\xe2\\x80\\x94 chord stack\",        text = \"<\"},\n            {label = \"$a = Do Re     \\xe2\\x80\\x94 define motif\",       text = \"$\"},\n            {label = \"$a             \\xe2\\x80\\x94 use motif\",           text = \"$\"},\n            {label = \"{Do|Re|Mi}     \\xe2\\x80\\x94 random pick\",        text = \"{\"},\n            {label = \"T:+5           \\xe2\\x80\\x94 transpose +5 semi\",  text = \"T:\"},\n        }},\n        {header = \"\\xe2\\x84\\xb9 Syntax\", items = {\n            {label = \"Do4 Re Mi   \\xe2\\x80\\x94 syllable + octave\", text = nil},\n            {label = \"/2 /4 /8    \\xe2\\x80\\x94 duration\",          text = nil},\n            {label = \"/d4 /8t     \\xe2\\x80\\x94 dotted / triplet\",  text = nil},\n            {label = \"|word        \\xe2\\x80\\x94 lyric tag\",         text = nil},\n            {label = \"--   ||      \\xe2\\x80\\x94 rest / bar line\",   text = nil},\n        }},\n        {header = \"\\xf0\\x9f\\x96\\xae Shortcuts\", items = {\n            {label = \"\\xe2\\x86\\x91 / \\xe2\\x86\\x93 on syllable \\xe2\\x80\\x94 cycle note\", text = nil},\n            {label = \"Tab          \\xe2\\x80\\x94 autocomplete\",                           text = nil},\n            {label = \"Cmd+Enter    \\xe2\\x80\\x94 play / pause\",                           text = nil},\n            {label = \"Cmd+Z / \\xe2\\x87\\xa7Z  \\xe2\\x80\\x94 undo / redo\",                 text = nil},\n            {label = \"Esc          \\xe2\\x80\\x94 exit text editor\",                       text = nil},\n        }},\n    }\nend\n\nlocal function drawSolfegeAllOptions(gfx, state)\n    if not state._solfegeAllOptionsOpen then\n        state._solfegeAllOptionsBtns = nil\n        state._solfegeAllOptionsTitleBar = nil\n        state._solfegeAllOptionsCloseBtn = nil\n        return\n    end\n\n    local sections = buildSolfegeOptionsSections(state)\n\n    gfx.setFont(\"normal\")\n    local itemH   = SOLFEGE_LINE_H + 2\n    local headerH = itemH - 1\n    local titleH  = 14\n    local panW    = 200\n\n    local bodyH = 4\n    for _, sec in ipairs(sections) do\n        bodyH = bodyH + headerH + #sec.items * itemH + 4\n    end\n    local totalH = titleH + bodyH\n\n    -- Initial position: top-right area, clear of toolbar\n    if not state._allOptionsPanelX then\n        state._allOptionsPanelX = SCREEN_W - panW - 4\n        state._allOptionsPanelY = 4\n    end\n    -- Apply any in-progress drag offset\n    local panX = state._allOptionsPanelX\n    local panY = state._allOptionsPanelY\n    -- Clamp to screen\n    panX = math.max(0, math.min(SCREEN_W - panW, panX))\n    panY = math.max(0, math.min(SCREEN_H - totalH, panY))\n    state._allOptionsPanelX = panX\n    state._allOptionsPanelY = panY\n\n    -- Shadow\n    gfx.setColor(0, 0, 0, 40)\n    gfx.fillRect(panX + 3, panY + 3, panW, totalH)\n    -- Body background\n    gfx.setColor(246, 247, 252, 252)\n    gfx.fillRect(panX, panY, panW, totalH)\n    gfx.setColor(110, 115, 140, 255)\n    gfx.drawRect(panX, panY, panW, totalH)\n\n    -- Title bar\n    gfx.setColor(70, 80, 130, 255)\n    gfx.fillRect(panX + 1, panY + 1, panW - 2, titleH - 1)\n    gfx.setColor(255, 255, 255, 220)\n    local tw, th = gfx.getTextSize(\"Text Options\")\n    gfx.drawText(\"Text Options\", panX + 5, panY + math.floor((titleH - th) / 2))\n    -- Close button in title bar\n    local closeW = 13\n    local closeX = panX + panW - closeW - 3\n    local closeY = panY + math.floor((titleH - closeW) / 2)\n    local mx, my = state.mouseX or -1, state.mouseY or -1\n    local hovClose = mx >= closeX and mx < closeX + closeW and my >= closeY and my < closeY + closeW\n    gfx.setColor(hovClose and 220 or 180, hovClose and 60 or 60, hovClose and 60 or 60, 255)\n    gfx.fillRect(closeX, closeY, closeW, closeW)\n    gfx.setColor(255, 255, 255, 240)\n    local xlw, xlh = gfx.getTextSize(\"\\xc3\\x97\")\n    gfx.drawText(\"\\xc3\\x97\", closeX + math.floor((closeW - xlw) / 2), closeY + math.floor((closeW - xlh) / 2))\n\n    state._solfegeAllOptionsTitleBar = {x = panX, y = panY, w = panW - closeW - 4, h = titleH}\n    state._solfegeAllOptionsCloseBtn = {x = closeX, y = closeY, w = closeW, h = closeW}\n\n    -- Content rows\n    local btns = {}\n    local cy = panY + titleH + 2\n\n    for _, sec in ipairs(sections) do\n        gfx.setColor(85, 90, 115, 210)\n        gfx.fillRect(panX + 1, cy, panW - 2, headerH)\n        gfx.setColor(255, 255, 255, 220)\n        local hw, hh = gfx.getTextSize(sec.header)\n        gfx.drawText(sec.header, panX + 5, cy + math.floor((headerH - hh) / 2))\n        cy = cy + headerH\n\n        for _, item in ipairs(sec.items) do\n            local label = type(item) == \"table\" and item.label or item\n            local text  = type(item) == \"table\" and item.text or item\n            local isHov = text and mx >= panX and mx < panX + panW and my >= cy and my < cy + itemH\n            if isHov then\n                gfx.setColor(205, 220, 245, 210)\n                gfx.fillRect(panX + 1, cy, panW - 2, itemH)\n            end\n            if text then\n                gfx.setColor(isHov and 20 or 45, isHov and 50 or 50, isHov and 120 or 75, 255)\n            else\n                gfx.setColor(120, 120, 135, 255)\n            end\n            gfx.drawText(label, panX + 5, cy + 1)\n            if text then\n                btns[#btns + 1] = {x = panX, y = cy, w = panW, h = itemH, text = text}\n            end\n            cy = cy + itemH\n        end\n        cy = cy + 4\n    end\n\n    state._solfegeAllOptionsBtns = btns\nend\n\nlocal function drawSolfegeTemplateMenu(gfx, state)\n    if not state._solfegeTemplateMenuOpen then\n        state._solfegeTemplateMenuItems = nil\n        state._solfegeTemplateCategoryHeaders = nil\n        return\n    end\n    local btn = state._solfegeTemplateBtn\n    if not btn then\n        state._solfegeTemplateMenuItems = nil\n        state._solfegeTemplateCategoryHeaders = nil\n        return\n    end\n    gfx.setFont(\"normal\")\n    local itemH      = btn.h + 2\n    local headerH    = math.max(10, itemH - 4)\n    local delW       = 16\n    local renW       = 16\n    local userTemplates = state.userSolfegeTemplates or {}\n    local isSaving      = state._solfegeTemplateSaving\n    local renamingIdx   = state._solfegeTemplateRenaming and state._solfegeTemplateRenaming.index\n    local searchStr     = (state._solfegeTemplateSearch or \"\"):lower()\n    local collapsed     = state._solfegeTemplateCollapsed or {}\n    local replaceMode   = state._solfegeTemplateReplaceMode == true\n\n    local function matchesSearch(name)\n        if searchStr == \"\" then return true end\n        return name:lower():find(searchStr, 1, true) ~= nil\n    end\n\n    local userVisible = 0\n    for _, t in ipairs(userTemplates) do\n        if matchesSearch(t.name) then userVisible = userVisible + 1 end\n    end\n\n    -- Measure menu width from all names\n    local menuW = 0\n    local function checkW(label, extra)\n        local tw = gfx.getTextSize(label)\n        if tw + 12 + (extra or 0) > menuW then menuW = tw + 12 + (extra or 0) end\n    end\n    for _, cat in ipairs(SOLFEGE_TEMPLATE_CATEGORIES) do\n        checkW(cat.name)\n        for _, t in ipairs(cat.templates) do checkW(t.name) end\n    end\n    for _, t in ipairs(userTemplates) do checkW(t.name, renW + delW) end\n    checkW(\"+ Save current\")\n    checkW(\"Append  Replace\")\n    menuW = math.max(menuW, 160)\n\n    -- Measure total height\n    local menuH = 2\n    menuH = menuH + itemH  -- search row\n    menuH = menuH + itemH  -- mode toggle row\n    for _, cat in ipairs(SOLFEGE_TEMPLATE_CATEGORIES) do\n        local visCount = 0\n        for _, t in ipairs(cat.templates) do\n            if matchesSearch(t.name) then visCount = visCount + 1 end\n        end\n        if searchStr == \"\" then\n            if visCount > 0 then\n                menuH = menuH + headerH\n                if not collapsed[cat.name] then menuH = menuH + visCount * itemH end\n            end\n        else\n            menuH = menuH + visCount * itemH\n        end\n    end\n    if userVisible > 0 then\n        if searchStr == \"\" then menuH = menuH + headerH end\n        menuH = menuH + userVisible * itemH\n    end\n    menuH = menuH + 1 + itemH  -- divider + save row\n\n    -- Position: prefer below button, flip above if needed\n    local screenH = SCREEN_H or 480\n    local screenW = SCREEN_W or 400\n    local menuX = btn.x\n    local menuY = btn.y + btn.h + 2\n    if menuY + menuH > screenH - 4 then menuY = btn.y - menuH - 2 end\n    if menuY < 4 then menuY = 4 end\n    if menuX + menuW > screenW - 4 then menuX = screenW - menuW - 4 end\n    if menuX < 0 then menuX = 0 end\n\n    -- Shadow + background\n    gfx.setColor(0, 0, 0, 40)\n    gfx.fillRect(menuX + 2, menuY + 2, menuW, menuH)\n    gfx.setColor(245, 245, 245, 250)\n    gfx.fillRect(menuX, menuY, menuW, menuH)\n    gfx.setColor(110, 110, 110, 255)\n    gfx.drawRect(menuX, menuY, menuW, menuH)\n\n    local mx, my = state.mouseX or -1, state.mouseY or -1\n    local items     = {}\n    local catHeaders = {}\n    local curY      = menuY + 1\n\n    local hoverText = nil\n    local hoverRowY = nil\n    local hoverRowH = nil\n\n    -- Helper: inline text input field\n    local function drawNameInput(buf, fieldX, fieldY, fieldW, fieldH, placeholder)\n        gfx.setColor(255, 255, 255, 255)\n        gfx.fillRect(fieldX, fieldY, fieldW, fieldH)\n        gfx.setColor(80, 110, 180, 255)\n        gfx.drawRect(fieldX, fieldY, fieldW, fieldH)\n        local textY = fieldY + math.floor((fieldH - 8) / 2)\n        if buf and #buf > 0 then\n            gfx.setColor(30, 30, 30, 255)\n            gfx.drawText(buf, fieldX + 4, textY)\n            local elapsed = (os.clock() % 1.0)\n            if elapsed < 0.55 then\n                local tw = gfx.getTextSize(buf)\n                gfx.setColor(30, 30, 30, 200)\n                gfx.fillRect(fieldX + 4 + tw + 1, fieldY + 2, 1, fieldH - 4)\n            end\n        elseif placeholder then\n            gfx.setColor(180, 180, 180, 255)\n            gfx.drawText(placeholder, fieldX + 4, textY)\n            local elapsed = (os.clock() % 1.0)\n            if elapsed < 0.55 then\n                gfx.setColor(160, 160, 160, 200)\n                gfx.fillRect(fieldX + 4, fieldY + 2, 1, fieldH - 4)\n            end\n        end\n    end\n\n    -- Search row\n    do\n        local s = state._solfegeTemplateSearch or \"\"\n        drawNameInput(s, menuX + 1, curY + 1, menuW - 2, itemH - 2, \"Filter\\xe2\\x80\\xa6\")\n        items[#items + 1] = {type = \"search_input\", x = menuX, y = curY, w = menuW, h = itemH}\n        curY = curY + itemH\n    end\n\n    -- Append / Replace mode toggle\n    do\n        local toggleH  = itemH\n        local halfW    = math.floor((menuW - 2) / 2)\n        local appendX  = menuX + 1\n        local replaceX = menuX + 1 + halfW\n        local appendW  = halfW\n        local replaceW = menuW - 2 - halfW\n        local aHov = mx >= appendX and mx < appendX + appendW and my >= curY and my < curY + toggleH\n        local rHov = mx >= replaceX and mx < replaceX + replaceW and my >= curY and my < curY + toggleH\n        if not replaceMode then\n            gfx.setColor(70, 100, 160, 230)\n            gfx.fillRect(appendX, curY + 1, appendW, toggleH - 2)\n            gfx.setColor(255, 255, 255, 255)\n        elseif aHov then\n            gfx.setColor(160, 180, 220, 180)\n            gfx.fillRect(appendX, curY + 1, appendW, toggleH - 2)\n            gfx.setColor(50, 60, 90, 255)\n        else\n            gfx.setColor(100, 100, 110, 255)\n        end\n        local aw, ah = gfx.getTextSize(\"Append\")\n        gfx.drawText(\"Append\", appendX + math.floor((appendW - aw) / 2), curY + math.floor((toggleH - ah) / 2))\n        if replaceMode then\n            gfx.setColor(150, 75, 35, 230)\n            gfx.fillRect(replaceX, curY + 1, replaceW, toggleH - 2)\n            gfx.setColor(255, 255, 255, 255)\n        elseif rHov then\n            gfx.setColor(220, 180, 155, 180)\n            gfx.fillRect(replaceX, curY + 1, replaceW, toggleH - 2)\n            gfx.setColor(80, 35, 15, 255)\n        else\n            gfx.setColor(100, 100, 110, 255)\n        end\n        local rw, rh = gfx.getTextSize(\"Replace\")\n        gfx.drawText(\"Replace\", replaceX + math.floor((replaceW - rw) / 2), curY + math.floor((toggleH - rh) / 2))\n        gfx.setColor(160, 160, 165, 200)\n        gfx.fillRect(replaceX, curY + 2, 1, toggleH - 4)\n        items[#items + 1] = {type = \"mode_toggle\", x = menuX, y = curY, w = menuW, h = toggleH,\n                              appendX = appendX, appendW = appendW,\n                              replaceX = replaceX, replaceW = replaceW}\n        curY = curY + toggleH\n    end\n\n    local function drawCategoryHeader(label)\n        local catCollapsed = collapsed[label]\n        local tri = catCollapsed and \"\\xe2\\x96\\xb8\" or \"\\xe2\\x96\\xbe\"  -- ▸ ▾\n        local triW = gfx.getTextSize(tri)\n        if triW == 0 then tri = catCollapsed and \">\" or \"v\"; triW = gfx.getTextSize(tri) end\n        gfx.setColor(140, 140, 140, 255)\n        gfx.drawText(tri, menuX + 4, curY + math.floor((headerH - 8) / 2))\n        local lw = gfx.getTextSize(label)\n        gfx.drawText(label, menuX + 4 + triW + 3, curY + math.floor((headerH - 8) / 2))\n        local lineX = menuX + 4 + triW + 3 + lw + 4\n        local lineY = curY + math.floor(headerH / 2)\n        gfx.setColor(200, 200, 200, 255)\n        gfx.fillRect(lineX, lineY, menuW - (lineX - menuX) - 4, 1)\n        catHeaders[#catHeaders + 1] = {name = label, x = menuX, y = curY, w = menuW, h = headerH}\n        curY = curY + headerH\n    end\n\n    local function drawTemplateRow(t, rowType, userIndex)\n        local hov = mx >= menuX and mx < menuX + menuW and my >= curY and my < curY + itemH\n        local isRenaming = (rowType == \"user_item\") and (renamingIdx == userIndex)\n\n        if isRenaming then\n            local dX     = menuX + menuW - delW\n            local fieldW = menuW - delW - 2\n            drawNameInput(state._solfegeTemplateRenaming.name,\n                          menuX + 1, curY + 1, fieldW - 1, itemH - 2, t.name)\n            local delHov = mx >= dX and mx < menuX + menuW and my >= curY and my < curY + itemH\n            if delHov then\n                gfx.setColor(180, 60, 60, 200)\n                gfx.fillRect(dX, curY + 1, delW - 1, itemH - 2)\n                gfx.setColor(255, 255, 255, 255)\n            else\n                gfx.setColor(160, 80, 80, 255)\n            end\n            local xsym = \"\\xc3\\x97\"\n            local xw = gfx.getTextSize(xsym)\n            if xw == 0 then xsym = \"x\"; xw = gfx.getTextSize(\"x\") end\n            gfx.drawText(xsym, dX + math.floor((delW - xw) / 2), curY + math.floor((itemH - 8) / 2))\n            items[#items + 1] = {type = \"user_item\", x = menuX, y = curY, w = menuW, h = itemH,\n                                 text = t.text, name = t.name, userIndex = userIndex,\n                                 deleteX = dX, deleteW = delW}\n        else\n            if hov then\n                gfx.setColor(80, 110, 150, 220)\n                gfx.fillRect(menuX + 1, curY, menuW - 2, itemH)\n                gfx.setColor(255, 255, 255, 255)\n            else\n                gfx.setColor(50, 50, 50, 255)\n            end\n            gfx.drawText(t.name, menuX + 6, curY + math.floor((itemH - 8) / 2))\n            if hov and t.text then\n                hoverText = t.text; hoverRowY = curY; hoverRowH = itemH\n            end\n            local item = {type = rowType, x = menuX, y = curY, w = menuW, h = itemH,\n                          text = t.text, name = t.name}\n            if rowType == \"user_item\" then\n                item.userIndex = userIndex\n                local dX  = menuX + menuW - delW\n                local rX  = dX - renW\n                item.deleteX = dX; item.deleteW = delW\n                item.renameX = rX; item.renameW = renW\n                local delHov = mx >= dX and mx < menuX + menuW and my >= curY and my < curY + itemH\n                local renHov = hov and mx >= rX and mx < dX\n                if delHov then\n                    gfx.setColor(180, 60, 60, 200)\n                    gfx.fillRect(dX, curY + 1, delW - 1, itemH - 2)\n                    gfx.setColor(255, 255, 255, 255)\n                elseif hov then\n                    gfx.setColor(255, 180, 180, 255)\n                else\n                    gfx.setColor(160, 80, 80, 255)\n                end\n                local xsym = \"\\xc3\\x97\"\n                local xw = gfx.getTextSize(xsym)\n                if xw == 0 then xsym = \"x\"; xw = gfx.getTextSize(\"x\") end\n                gfx.drawText(xsym, dX + math.floor((delW - xw) / 2), curY + math.floor((itemH - 8) / 2))\n                if hov then\n                    if renHov then\n                        gfx.setColor(80, 110, 180, 200)\n                        gfx.fillRect(rX, curY + 1, renW - 1, itemH - 2)\n                        gfx.setColor(255, 255, 255, 255)\n                    else\n                        gfx.setColor(160, 160, 200, 255)\n                    end\n                    local psym = \"\\xe2\\x9c\\x8e\"\n                    local pw = gfx.getTextSize(psym)\n                    if pw == 0 then psym = \"~\"; pw = gfx.getTextSize(\"~\") end\n                    gfx.drawText(psym, rX + math.floor((renW - pw) / 2), curY + math.floor((itemH - 8) / 2))\n                end\n            end\n            items[#items + 1] = item\n        end\n        curY = curY + itemH\n    end\n\n    -- Built-in categories\n    for _, cat in ipairs(SOLFEGE_TEMPLATE_CATEGORIES) do\n        if searchStr == \"\" then\n            local visCount = 0\n            for _, t in ipairs(cat.templates) do\n                if matchesSearch(t.name) then visCount = visCount + 1 end\n            end\n            if visCount > 0 then\n                drawCategoryHeader(cat.name)\n                if not collapsed[cat.name] then\n                    for _, t in ipairs(cat.templates) do drawTemplateRow(t, \"item\") end\n                end\n            end\n        else\n            for _, t in ipairs(cat.templates) do\n                if matchesSearch(t.name) then drawTemplateRow(t, \"item\") end\n            end\n        end\n    end\n\n    -- User templates\n    if userVisible > 0 then\n        if searchStr == \"\" then drawCategoryHeader(\"My Templates\") end\n        for i, t in ipairs(userTemplates) do\n            if matchesSearch(t.name) then drawTemplateRow(t, \"user_item\", i) end\n        end\n    end\n\n    -- Divider + Save row\n    gfx.setColor(200, 200, 200, 255)\n    gfx.fillRect(menuX + 1, curY, menuW - 2, 1)\n    curY = curY + 1\n    local saveH     = itemH\n    local bufBody   = (state.solfegeInputBuffer or state._solfegeSeqText or \"\"):match(\"\\n(.+)\") or \"\"\n    bufBody = bufBody:match(\"^%s*(.-)%s*$\") or \"\"\n    local canSave   = bufBody ~= \"\"\n    local savedFlash = state._solfegeTemplateSavedFlash\n    local showFlash  = savedFlash and (os.clock() - savedFlash < 1.5)\n    if isSaving then\n        local saveName = state._solfegeTemplateSaveName or \"\"\n        drawNameInput(saveName, menuX + 1, curY + 1, menuW - 2, saveH - 2, \"My Pattern\")\n        items[#items + 1] = {type = \"save_input\", x = menuX, y = curY, w = menuW, h = saveH}\n    elseif showFlash then\n        local ck = \"\\xe2\\x9c\\x93\"\n        if gfx.getTextSize(ck) == 0 then ck = \"+\" end\n        gfx.setColor(35, 120, 55, 255)\n        gfx.drawText(ck .. \" Saved!\", menuX + 6, curY + math.floor((saveH - 8) / 2))\n        items[#items + 1] = {type = \"save_flash\", x = menuX, y = curY, w = menuW, h = saveH}\n    elseif canSave then\n        local saveHov = mx >= menuX and mx < menuX + menuW and my >= curY and my < curY + saveH\n        if saveHov then\n            gfx.setColor(70, 120, 70, 220)\n            gfx.fillRect(menuX + 1, curY, menuW - 2, saveH)\n            gfx.setColor(255, 255, 255, 255)\n        else\n            gfx.setColor(55, 100, 55, 255)\n        end\n        gfx.drawText(\"+ Save current\", menuX + 6, curY + math.floor((saveH - 8) / 2))\n        items[#items + 1] = {type = \"save\", x = menuX, y = curY, w = menuW, h = saveH}\n    else\n        gfx.setColor(175, 175, 175, 255)\n        gfx.drawText(\"+ Save current\", menuX + 6, curY + math.floor((saveH - 8) / 2))\n        items[#items + 1] = {type = \"save_disabled\", x = menuX, y = curY, w = menuW, h = saveH}\n    end\n\n    state._solfegeTemplateMenuItems = items\n    state._solfegeTemplateCategoryHeaders = catHeaders\n\n    -- Preview tooltip for hovered template\n    if hoverText and #hoverText > 0 then\n        local tipPad = 6\n        local tipH   = itemH\n        local tipTw  = gfx.getTextSize(hoverText)\n        local maxTipW = 220\n        local tipText = hoverText\n        if tipTw + tipPad * 2 > maxTipW then\n            local ellipsis = \"\\xe2\\x80\\xa6\"\n            local ew = gfx.getTextSize(ellipsis)\n            while #tipText > 1 and gfx.getTextSize(tipText) + ew + tipPad * 2 > maxTipW do\n                tipText = tipText:sub(1, -2)\n            end\n            tipText = tipText .. ellipsis\n            tipTw = gfx.getTextSize(tipText)\n        end\n        local tipW = tipTw + tipPad * 2\n        local tipX = menuX + menuW + 4\n        if tipX + tipW > screenW - 2 then tipX = menuX - tipW - 4 end\n        if tipX < 2 then tipX = 2 end\n        local tipY = hoverRowY\n        if tipY + tipH > screenH - 2 then tipY = screenH - tipH - 2 end\n        gfx.setColor(0, 0, 0, 30)\n        gfx.fillRect(tipX + 2, tipY + 2, tipW, tipH)\n        gfx.setColor(255, 255, 240, 245)\n        gfx.fillRect(tipX, tipY, tipW, tipH)\n        gfx.setColor(140, 140, 100, 200)\n        gfx.drawRect(tipX, tipY, tipW, tipH)\n        gfx.setColor(80, 80, 80, 255)\n        gfx.drawText(tipText, tipX + tipPad, tipY + math.floor((tipH - 8) / 2))\n    end\nend\n\n-- Helper: draw a text line, rendering || paragraph markers as soft gray ¶ symbols\n-- Find the start of the word at absolute buffer position p (1..#buf+1).\n-- Returns the absolute position of the first char of the word (or p if not on a word char).\nlocal _textEdit = require(\"text_edit\")\nlocal solfegeWordStart = _textEdit.wordStart\nlocal solfegeWordEnd   = _textEdit.wordEnd\nlocal solfegeLineStart = _textEdit.lineStart\nlocal solfegeLineEnd   = _textEdit.lineEnd\n\nlocal function drawSolfegeSelBar(gfx, state, barBottomY, barMinX, barMaxX, boxX, boxY, boxW, boxH, activeFont)\n    state._solfegeSelBar = nil\nend\n\nlocal function drawSolfegeCtxMenu(gfx, state)\n    local ctx = state and state._solfegeCtxMenu\n    if not ctx then return end\n    local mx = state.mouseX or 0\n    local my = state.mouseY or 0\n    -- shadow\n    gfx.setColor(0, 0, 0, 60)\n    gfx.fillRect(ctx.x + 2, ctx.y + 2, ctx.w, ctx.h)\n    -- background\n    gfx.setColor(38, 38, 38, 250)\n    gfx.fillRect(ctx.x, ctx.y, ctx.w, ctx.h)\n    -- border\n    gfx.setColor(90, 90, 90, 220)\n    gfx.drawRect(ctx.x, ctx.y, ctx.w, ctx.h)\n    gfx.setFont(\"small\")\n    for _, item in ipairs(ctx.items) do\n        local b = item.bounds\n        if b then\n            local hovered = mx >= b.x and my >= b.y and mx < b.x + b.w and my < b.y + b.h\n            if hovered and item.enabled then\n                gfx.setColor(65, 105, 200, 220)\n                gfx.fillRect(b.x + 2, b.y + 1, b.w - 4, b.h - 2)\n            end\n            if item.enabled then\n                gfx.setColor(225, 225, 225, 255)\n            else\n                gfx.setColor(100, 100, 100, 255)\n            end\n            local _, th = gfx.getTextSize(item.label)\n            gfx.drawText(item.label, b.x + 10, b.y + math.floor((b.h - th) / 2))\n        end\n    end\n    gfx.setFont(\"bold\")\n    gfx.setColor(0, 0, 0, 255)\nend\n\nlocal function drawSolfegeLineText(gfx, text, x, y)\n    if not text or text == \"\" then return end  -- skip empty lines (SDL TTF renders artifact for \"\")\n    if not text:find(\"||\", 1, true) then\n        gfx.setColor(0, 0, 0, 255)\n        gfx.drawText(text, x, y)\n        return\n    end\n    local px = x\n    local pos = 1\n    while pos <= #text do\n        local s, e = text:find(\"||\", pos, true)\n        if s then\n            if s > pos then\n                gfx.setColor(0, 0, 0, 255)\n                local seg = text:sub(pos, s - 1)\n                gfx.drawText(seg, px, y)\n                px = px + gfx.getTextSize(seg)\n            end\n            gfx.setColor(180, 180, 180, 255)\n            gfx.drawText(\"\\182\", px, y)  -- pilcrow ¶\n            px = px + gfx.getTextSize(\"\\182\")\n            pos = e + 1\n        else\n            gfx.setColor(0, 0, 0, 255)\n            gfx.drawText(text:sub(pos), px, y)\n            break\n        end\n    end\nend\n\n-- Helper: draw centered text\nlocal function drawCenteredText(gfx, text, y, xMin, xMax)\n    xMin = xMin or 0\n    xMax = xMax or SCREEN_W\n    local tw = gfx.getTextSize(text)\n    gfx.drawText(text, math.floor(xMin + (xMax - xMin - tw) / 2), y)\nend\n\n-- Helper: draw centered menu title with underline accent on desktop\nlocal function drawMenuTitle(gfx, text, y, isMacDesktop)\n    local tw, th = gfx.getTextSize(text)\n    local tx = math.floor((SCREEN_W - tw) / 2)\n    gfx.drawText(text, tx, y)\n    if isMacDesktop then\n        -- Short decorative underline accent\n        local underlineW = math.min(tw + 16, 120)\n        local underlineX = math.floor((SCREEN_W - underlineW) / 2)\n        gfx.drawLine(underlineX, y + th + 2, underlineX + underlineW, y + th + 2)\n    end\nend\n\n-- Helper: draw section divider line (thicker on desktop)\nlocal function drawSectionDivider(gfx, y, isMacDesktop, x1, x2)\n    x1 = x1 or 10\n    x2 = x2 or 390\n    if isMacDesktop then\n        gfx.setLineWidth(2)\n        gfx.drawLine(x1, y, x2, y)\n        gfx.setLineWidth(1)\n    else\n        gfx.drawLine(x1, y, x2, y)\n    end\nend\n\n-- Helper: draw list viewport indicators only when not all items are visible.\nlocal function drawListViewportStatus(gfx, firstVisible, lastVisible, totalItems, startY, instructY, countY)\n    if totalItems <= 0 or lastVisible <= 0 then\n        return\n    end\n\n    if totalItems > (lastVisible - firstVisible + 1) then\n        local rangeText = firstVisible .. \"-\" .. lastVisible .. \"/\" .. totalItems\n        local rangeWidth = gfx.getTextSize(rangeText)\n        gfx.drawText(rangeText, SCREEN_W - rangeWidth - 10, countY or 34)\n\n        if firstVisible > 1 then\n            drawCenteredText(gfx, \"▲\", startY - 12)\n        end\n        if lastVisible < totalItems then\n            drawCenteredText(gfx, \"▼\", instructY - 12)\n        end\n    end\nend\n\nlocal function drawSequenceSidebar(gfx, state, ctx, isMacDesktop, extraOffsetY)\n    local core = ctx.core\n    local solfegeNotes = ctx.solfegeNotes\n    local sidebarY = SIDEBAR_START_Y + (extraOffsetY or 0)\n\n    -- Draw sidebar background separator\n    if isMacDesktop then\n        gfx.setLineWidth(2)\n        gfx.drawLine(SIDEBAR_WIDTH, sidebarY, SIDEBAR_WIDTH, SCREEN_H)\n        gfx.setLineWidth(1)\n    else\n        gfx.drawLine(SIDEBAR_WIDTH, sidebarY, SIDEBAR_WIDTH, SCREEN_H)\n    end\n\n    -- List sequences (active + non-empty ones)\n    local entryIndex = 0\n    for i = 1, core.maxSequences do\n        local hasContent = state.sequences[i] and (state.sequenceLengths[i] or 0) > 0\n        if hasContent or i == state.activeSequenceIndex then\n            local y = sidebarY + entryIndex * SIDEBAR_ENTRY_HEIGHT\n            if y + SIDEBAR_ENTRY_HEIGHT > SCREEN_H then break end\n\n            local isActive = (i == state.activeSequenceIndex)\n\n            -- Build compact label: \"N:DoRe\"\n            local preview = \"\"\n            local seq = state.sequences[i]\n            local len = state.sequenceLengths[i] or 0\n            for s = 1, math.min(2, len) do\n                local stepData = seq[s]\n                if stepData and stepData.note then\n                    local noteName = solfegeNotes[stepData.note + 1] or \"--\"\n                    -- Truncate to 2 chars\n                    preview = preview .. noteName:sub(1, 2)\n                elseif stepData and stepData.notes and stepData.notes[1] then\n                    local noteName = solfegeNotes[stepData.notes[1].note + 1] or \"--\"\n                    preview = preview .. noteName:sub(1, 2)\n                end\n            end\n\n            local label = i .. \":\" .. preview\n\n            if isActive then\n                if isMacDesktop then\n                    gfx.fillRoundRect(2, y, SIDEBAR_WIDTH - 4, SIDEBAR_ENTRY_HEIGHT, 3)\n                else\n                    gfx.fillRect(0, y, SIDEBAR_WIDTH - 1, SIDEBAR_ENTRY_HEIGHT)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n\n            gfx.setFont(\"normal\")\n            gfx.drawText(label, 6, y + 2)\n            gfx.setDrawMode(\"copy\")\n\n            entryIndex = entryIndex + 1\n        end\n    end\n\n    -- Draw bottom sidebar button: Mute\n    local muteY = SCREEN_H - SIDEBAR_ENTRY_HEIGHT - 2\n    gfx.drawLine(0, muteY - 2, SIDEBAR_WIDTH - 1, muteY - 2)\n    gfx.setFont(\"normal\")\n\n    local muteLabel = state.audioMuted and \"Mute ON\" or \"Mute OFF\"\n    if state.audioMuted then\n        if isMacDesktop then\n            gfx.fillRoundRect(2, muteY, SIDEBAR_WIDTH - 4, SIDEBAR_ENTRY_HEIGHT, 3)\n        else\n            gfx.fillRect(0, muteY, SIDEBAR_WIDTH - 1, SIDEBAR_ENTRY_HEIGHT)\n        end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.drawText(muteLabel, 4, muteY + 2)\n        gfx.setDrawMode(\"copy\")\n    else\n        gfx.drawText(muteLabel, 4, muteY + 2)\n    end\nend\n\nlocal function getCurrentTimeMilliseconds(ctx)\n    if ctx.getCurrentTimeMilliseconds then\n        return ctx.getCurrentTimeMilliseconds()\n    end\n    return math.floor(os.clock() * 1000)\nend\n\nlocal function getPitchStatusSymbol(state)\n    if state.pitchMatchStatus == \"match\" then\n        return \"✓\"\n    elseif state.pitchMatchStatus == \"close\" then\n        return \"~\"\n    elseif state.pitchMatchStatus == \"off\" then\n        return \"×\"\n    elseif state.pitchMatchStatus == \"quiet\" then\n        return \"!\"\n    elseif state.pitchMatchStatus == \"silent\" then\n        return \"...\"\n    elseif state.pitchMatchStatus == \"unsupported\" then\n        return \"N/A\"\n    end\n    return \"\"\nend\n\nlocal function drawPitchMeter(gfx, y, state, solfegeNotes, isMacDesktop, barX_override, barWidth_override)\n    local barX = barX_override or 4\n    local barWidth = barWidth_override or (SCREEN_W - 8)\n    local meterHeight = 10\n\n    -- Status text for non-singing states (centered)\n    if state.singSolfegeRestStep then\n        drawCenteredText(gfx, \"Rest\", y)\n        return\n    end\n    if state.singSolfegeAutoOctaveMessage then\n        drawCenteredText(gfx, state.singSolfegeAutoOctaveMessage, y)\n        return\n    end\n    if state.pitchMatchStatus == \"unsupported\" then\n        drawCenteredText(gfx, \"No mic\", y)\n        return\n    end\n\n    -- Draw meter outline\n    if isMacDesktop then\n        gfx.drawRoundRect(barX, y, barWidth, meterHeight, 3)\n    else\n        gfx.drawRect(barX, y, barWidth, meterHeight)\n    end\n\n    local barCenterX = barX + barWidth / 2\n\n    -- Draw tick marks on desktop for scale reference (at ±50 and ±100 cents)\n    if isMacDesktop then\n        local maxCentsRange = 200\n        local tickHeight = 2\n        for _, cents in ipairs({-100, -50, 50, 100}) do\n            local tickX = math.floor(barCenterX + (cents / maxCentsRange) * (barWidth / 2 - 2))\n            gfx.drawLine(tickX, y + meterHeight - tickHeight, tickX, y + meterHeight)\n        end\n    end\n\n    if state.pitchMatchStatus == \"quiet\" or state.pitchMatchStatus == \"silent\" or state.pitchExpectedNote == nil then\n        -- Draw center target line (thicker on desktop)\n        if isMacDesktop then\n            gfx.setLineWidth(2)\n            gfx.drawLine(barCenterX, y, barCenterX, y + meterHeight)\n            gfx.setLineWidth(1)\n        else\n            gfx.drawLine(barCenterX, y, barCenterX, y + meterHeight)\n        end\n        return\n    end\n\n    -- Draw center target line (thicker on desktop for prominence)\n    if isMacDesktop then\n        gfx.setLineWidth(2)\n        gfx.drawLine(barCenterX, y, barCenterX, y + meterHeight)\n        gfx.setLineWidth(1)\n    else\n        gfx.drawLine(barCenterX, y, barCenterX, y + meterHeight)\n    end\n\n    -- Draw hold progress as fill from center outward\n    if state.pitchHoldTargetMs and state.pitchHoldTargetMs > 0 then\n        local holdMs = state.pitchHoldMs or 0\n        local holdFrac = math.min(1, holdMs / state.pitchHoldTargetMs)\n        if holdFrac > 0 then\n            local fillHalf = math.floor((barWidth / 2 - 1) * holdFrac)\n            gfx.fillRect(barCenterX - fillHalf, y + 1, fillHalf * 2, meterHeight - 2)\n        end\n    end\n\n    -- Draw pitch needle only when mic level indicates real singing\n    local singing = state.pitchMatchCents ~= nil and (state.pitchMatchStatus == \"match\" or state.pitchMatchStatus == \"close\") and (state.micLevel or 0) > 0.3\n    if singing then\n        local cents = state.pitchMatchCents\n        local maxCents = 200\n        local clampedCents = math.max(-maxCents, math.min(maxCents, cents))\n        -- Smooth needle position to reduce jitter\n        local targetOffset = (clampedCents / maxCents) * (barWidth / 2 - 2)\n        if not state._smoothedNeedleOffset then\n            state._smoothedNeedleOffset = targetOffset\n        else\n            state._smoothedNeedleOffset = state._smoothedNeedleOffset + (targetOffset - state._smoothedNeedleOffset) * 0.15\n        end\n        local needleX = math.floor(barCenterX + state._smoothedNeedleOffset)\n\n        if isMacDesktop then\n            -- Thicker needle for visibility\n            gfx.setLineWidth(2)\n            gfx.drawLine(needleX, y - 1, needleX, y + meterHeight + 1)\n            gfx.setLineWidth(1)\n        else\n            gfx.setDrawMode(\"NXOR\")\n            gfx.fillRect(needleX - 1, y - 2, 3, meterHeight + 4)\n            gfx.setDrawMode(\"copy\")\n        end\n    else\n        state._smoothedNeedleOffset = nil\n    end\nend\n\nlocal function drawMicLevelIndicator(gfx, y, state, isMacDesktop, barX_override, barWidth_override)\n    local barX = barX_override or 4\n    local barWidth = barWidth_override or (SCREEN_W - 8)\n    local meterHeight = 10\n\n    if state.pitchMatchStatus == \"unsupported\" then\n        drawCenteredText(gfx, \"No mic\", y)\n        return\n    end\n\n    -- Bar outline\n    if isMacDesktop then\n        gfx.drawRoundRect(barX, y, barWidth, meterHeight, 3)\n    else\n        gfx.drawRect(barX, y, barWidth, meterHeight)\n    end\n\n    -- Mic level fill (left to right) — use display-smoothed value for the meter\n    local micLvl = math.min(1, state.micLevelDisplay or state.micLevel or 0)\n    if micLvl > 0.01 then\n        local fillW = math.max(1, math.floor((barWidth - 2) * micLvl))\n        gfx.fillRect(barX + 1, y + 1, fillW, meterHeight - 2)\n    end\n\n    -- Hold progress overlay (center outward, inverted) when a pitch is held\n    if state.pitchHoldTargetMs and state.pitchHoldTargetMs > 0 then\n        local holdMs = state.pitchHoldMs or 0\n        local holdFrac = math.min(1, holdMs / state.pitchHoldTargetMs)\n        if holdFrac > 0 then\n            local barCenterX = barX + barWidth / 2\n            local fillHalf = math.floor((barWidth / 2 - 1) * holdFrac)\n            gfx.setDrawMode(\"fillWhite\")\n            gfx.fillRect(barCenterX - fillHalf, y + 1, fillHalf * 2, meterHeight - 2)\n            gfx.setDrawMode(\"copy\")\n        end\n    end\nend\n\nlocal CORNER_RADIUS = 6\n\nlocal function setAbsoluteColor(gfx, state, r, g, b, a)\n    if state and state.darkMode then\n        gfx.setColor(255 - r, 255 - g, 255 - b, a or 255)\n    else\n        gfx.setColor(r, g, b, a or 255)\n    end\nend\n\nlocal UI_THEME = {\n    appBg = {245, 247, 250, 255},\n    chromeTop = {250, 251, 253, 255},\n    chromeMid = {242, 244, 249, 255},\n    chromeLine = {218, 222, 232, 255},\n    button = {255, 255, 255, 255},\n    buttonHover = {224, 234, 250, 255},\n    buttonBorder = {196, 202, 218, 255},\n    buttonActive = {60, 115, 210, 255},\n    buttonActiveBorder = {40, 82, 168, 255},\n    panel = {236, 239, 246, 255},\n    panelLine = {168, 178, 202, 255},\n    input = {255, 255, 255, 255},\n    inputActive = {55, 120, 218, 255},\n    inputIdle = {148, 156, 172, 255},\n    text = {32, 36, 46, 255},\n}\n\nlocal function setThemeColor(gfx, state, key)\n    local c = UI_THEME[key] or UI_THEME.text\n    setAbsoluteColor(gfx, state, c[1], c[2], c[3], c[4])\nend\n\nlocal function drawThemeRect(gfx, state, key, x, y, w, h)\n    setThemeColor(gfx, state, key)\n    gfx.fillRect(x, y, w, h)\nend\n\nlocal function drawThemeLine(gfx, state, key, x1, y1, x2, y2)\n    setThemeColor(gfx, state, key)\n    gfx.drawLine(x1, y1, x2, y2)\nend\n\nlocal function drawChromeButton(gfx, state, x, y, w, h, label, isActive, isMacDesktop)\n    local mx = state.mouseX or -1\n    local my = state.mouseY or -1\n    local hovered = mx >= x and mx < x + w and my >= y and my < y + h\n    local radius = isMacDesktop and (_touchMode and 10 or 6) or 0\n\n    if isActive then\n        setThemeColor(gfx, state, \"buttonActive\")\n    elseif hovered then\n        setThemeColor(gfx, state, \"buttonHover\")\n    else\n        setThemeColor(gfx, state, \"button\")\n    end\n    if isMacDesktop then\n        gfx.fillRoundRect(x, y, w, h, radius)\n    else\n        gfx.fillRect(x, y, w, h)\n    end\n\n    if not isActive then\n        setThemeColor(gfx, state, \"buttonBorder\")\n        if isMacDesktop then\n            gfx.drawRoundRect(x, y, w, h, radius)\n        else\n            gfx.drawRect(x, y, w, h)\n        end\n    end\n\n    if isActive then\n        gfx.setColor(255, 255, 255, 255)\n    else\n        setThemeColor(gfx, state, \"text\")\n    end\n    local tw, th = gfx.getTextSize(label)\n    gfx.drawText(label, x + math.floor((w - tw) / 2), y + math.floor((h - th) / 2))\nend\n\nlocal function drawStepCell(gfx, isMacDesktop, x, y, width, height)\n    gfx.drawRect(x, y, width, height)\nend\n\nlocal function fillStepCell(gfx, isMacDesktop, x, y, width, height)\n    gfx.fillRect(x, y, width, height)\nend\n\nlocal function drawCurrentStepEmphasis(gfx, isMacDesktop, x, y, width, height, useShapeNoteStaff)\n    if not useShapeNoteStaff then\n        -- Fill cell black, then set fillWhite so subsequent text renders inverted\n        fillStepCell(gfx, isMacDesktop, x, y, width, height)\n        gfx.setDrawMode(\"fillWhite\")\n    else\n        gfx.setLineWidth(4)\n        gfx.drawLine(x, y + height + 4, x + width, y + height + 4)\n        gfx.setLineWidth(1)\n    end\nend\n\nlocal _gfx = nil  -- cached for ui.measureText\n\nfunction ui.measureText(text)\n    if _gfx and _gfx.getTextSize then\n        local w = _gfx.getTextSize(text)\n        return w or (#tostring(text) * 6)\n    end\n    return #tostring(text) * 6\nend\n\n-- Set the active font on the cached gfx so that subsequent ui.measureText calls\n-- use the correct font (important when measuring during mouse events, where the\n-- active font may differ from the one used during rendering).\nfunction ui.setMeasureFont(fontName)\n    if _gfx and _gfx.setFont then\n        _gfx.setFont(fontName or \"normal\")\n    end\nend\n\nlocal CMD_CHAT_LIGHT_COLORS = {\n    toggleOpen = {52, 92, 150},\n    toggleClosed = {224, 228, 236},\n    toggleHover = {204, 214, 230},\n    toggleText = {255, 255, 255},\n    toggleClosedText = {55, 61, 72},\n    panelBg = {250, 250, 252},\n    panelTop = {206, 211, 220},\n    resizeIdle = {162, 168, 178},\n    resizeHover = {75, 125, 200},\n    headerBg = {238, 240, 244},\n    headerRule = {209, 214, 222},\n    headerText = {32, 35, 42},\n    headerSubtle = {93, 100, 112},\n    badgeBg = {255, 255, 255},\n    badgeBorder = {181, 188, 200},\n    activityActive = {54, 145, 92},\n    activityIdle = {143, 150, 162},\n    inputBg = {255, 255, 255},\n    inputBorder = {170, 176, 188},\n    inputBorderActive = {70, 122, 210},\n    prompt = {52, 92, 150},\n    placeholderText = {122, 128, 140},\n    text = {24, 27, 32},\n    userText = {18, 89, 140},\n    systemText = {40, 43, 50},\n    errorText = {190, 58, 40},\n    userRow = {229, 241, 255},\n    systemRow = {244, 246, 249},\n    errorRow = {255, 235, 232},\n    scrollTrack = {220, 224, 230},\n    scrollThumb = {142, 150, 162},\n    muteActive = {198, 62, 48},\n    muteIdle = {224, 228, 236},\n    muteBorder = {134, 142, 154},\n}\nlocal CMD_CHAT_DARK_COLORS = {\n    toggleOpen = {82, 135, 220},\n    toggleClosed = {50, 55, 64},\n    toggleHover = {66, 74, 88},\n    toggleText = {255, 255, 255},\n    toggleClosedText = {225, 230, 238},\n    panelBg = {22, 24, 29},\n    panelTop = {68, 73, 84},\n    resizeIdle = {82, 88, 100},\n    resizeHover = {110, 160, 235},\n    headerBg = {31, 34, 40},\n    headerRule = {73, 78, 88},\n    headerText = {235, 238, 244},\n    headerSubtle = {155, 164, 178},\n    badgeBg = {18, 20, 25},\n    badgeBorder = {86, 92, 104},\n    activityActive = {82, 190, 125},\n    activityIdle = {128, 136, 150},\n    inputBg = {14, 16, 20},\n    inputBorder = {86, 92, 104},\n    inputBorderActive = {112, 164, 245},\n    prompt = {144, 184, 255},\n    placeholderText = {135, 142, 154},\n    text = {238, 241, 246},\n    userText = {154, 199, 255},\n    systemText = {221, 225, 232},\n    errorText = {255, 135, 118},\n    userRow = {30, 46, 68},\n    systemRow = {29, 32, 38},\n    errorRow = {72, 37, 34},\n    scrollTrack = {48, 52, 60},\n    scrollThumb = {132, 140, 154},\n    muteActive = {215, 86, 72},\n    muteIdle = {50, 55, 64},\n    muteBorder = {116, 124, 138},\n}\n\nlocal _chatWrappedCache = nil\nlocal _chatWrappedCacheKey = nil\n\nlocal function _drawCmdChatPanel(gfx, state)\n    local function chatColors()\n        return (state and state.darkMode) and CMD_CHAT_DARK_COLORS or CMD_CHAT_LIGHT_COLORS\n    end\n\n    local function setChatColor(key, alpha)\n        local c = chatColors()[key]\n        gfx.setColor(c[1], c[2], c[3], alpha or 255)\n    end\n\n    local function drawFittedText(text, x, y, maxW)\n        text = tostring(text or \"\")\n        if select(1, gfx.getTextSize(text)) <= maxW then\n            gfx.drawText(text, x, y)\n            return\n        end\n        while #text > 1 and select(1, gfx.getTextSize(text .. \"...\")) > maxW do\n            text = text:sub(1, -2)\n        end\n        gfx.drawText(text .. \"...\", x, y)\n    end\n\n    local function getChatArea(useScreenBottomForFullWidthPanel)\n        local areaX, areaW = 0, SCREEN_W\n        local _syllReserve = (state.showSolfegeButtons ~= false and not state.solfegeTextOnlyMode) and SOLFEGE_SYLLABLE_ROW_H or 0\n        local bottomY = SCREEN_H - _syllReserve - _safeAreaBottom\n        local textPanel = state._solfegePanelBounds\n        if textPanel then\n            if textPanel.x <= 0 and textPanel.w < SCREEN_W and textPanel.h > SCREEN_H * 0.5 then\n                areaX = textPanel.x + textPanel.w\n                areaW = SCREEN_W - areaX\n            elseif textPanel.x > 0 and textPanel.x + textPanel.w >= SCREEN_W and textPanel.h > SCREEN_H * 0.5 then\n                areaX = 0\n                areaW = textPanel.x\n            elseif textPanel.x <= 0 and textPanel.w >= SCREEN_W - 1 and not useScreenBottomForFullWidthPanel then\n                bottomY = textPanel.y\n            end\n        end\n        return areaX, math.max(80, areaW), math.max(0, bottomY)\n    end\n\n    -- Toggle button — bottom-right floating\n    do\n        local cmdBtnW, cmdBtnH = _touchMode and 72 or 34, _touchMode and 36 or 16\n        local areaX, areaW, bottomY = getChatArea(state.cmdChatOpen == true)\n        local cmdBtnX = areaX + areaW - cmdBtnW - 6\n        local cmdBtnY\n        if state.cmdChatOpen then\n            local maxChatH = math.max(0, math.min(math.floor(SCREEN_H * 0.6), bottomY - 8))\n            local chatH = math.min(maxChatH, math.floor(state.cmdChatBottomH or 60))\n            cmdBtnY = bottomY - chatH - cmdBtnH - 3\n        else\n            cmdBtnY = bottomY - cmdBtnH - 6\n        end\n        cmdBtnY = math.max(2, cmdBtnY)\n        local mx, my = state.mouseX or -1, state.mouseY or -1\n        local cmdHov = mx >= cmdBtnX and mx < cmdBtnX + cmdBtnW and my >= cmdBtnY and my < cmdBtnY + cmdBtnH\n        gfx.setFont(_touchMode and \"normal\" or \"small\")\n        local toggleR = _touchMode and 8 or 3\n        if state.cmdChatOpen then\n            setChatColor(\"toggleOpen\")\n            gfx.fillRoundRect(cmdBtnX, cmdBtnY, cmdBtnW, cmdBtnH, toggleR)\n            setChatColor(\"toggleText\")\n        elseif cmdHov then\n            setChatColor(\"toggleHover\", 240)\n            gfx.fillRoundRect(cmdBtnX, cmdBtnY, cmdBtnW, cmdBtnH, toggleR)\n            setChatColor(\"toggleClosedText\")\n        else\n            setChatColor(\"toggleClosed\", 230)\n            gfx.fillRoundRect(cmdBtnX, cmdBtnY, cmdBtnW, cmdBtnH, toggleR)\n            setChatColor(\"toggleClosedText\")\n        end\n        gfx.drawRoundRect(cmdBtnX, cmdBtnY, cmdBtnW, cmdBtnH, toggleR)\n        local cmdLbl = \"Activity\"\n        local cmdLw, cmdLh = gfx.getTextSize(cmdLbl)\n        gfx.drawText(cmdLbl, cmdBtnX + math.floor((cmdBtnW - cmdLw) / 2), cmdBtnY + math.floor((cmdBtnH - cmdLh) / 2))\n        gfx.setColor(0, 0, 0, 255)\n        gfx.setFont(\"bold\")\n        state._cmdChatToggleBtn = {x = cmdBtnX, y = cmdBtnY, w = cmdBtnW, h = cmdBtnH}\n    end\n\n    -- Drawer panel\n    if state.cmdChatOpen then\n        local CMD_LINE_H = _touchMode and 20 or 13\n        local CMD_PAD = _touchMode and 10 or 6\n        local CMD_HEADER_H = _touchMode and 36 or 17\n        local CMD_INPUT_H = _touchMode and 44 or 18\n        local CMD_RESIZE_H = _touchMode and 10 or 4\n        local areaX, areaW, bottomY = getChatArea(true)\n        local maxAreaH = math.max(0, math.min(math.floor(SCREEN_H * 0.6), bottomY - 6))\n        local areaH = math.floor(state.cmdChatBottomH or 100)\n        areaH = math.min(maxAreaH, math.max(48, areaH))\n        if areaH < 36 then\n            state._cmdChatPanelBounds = nil\n            state._cmdChatResizeHandle = nil\n            state._cmdChatInputBounds = nil\n            state._cmdChatMuteBtn = nil\n            state._cmdChatMessageBounds = nil\n            state._cmdChatScrollMax = 0\n            state._cmdChatScrollTrack = nil\n            state._cmdChatInputTextMetrics = nil\n            return\n        end\n        local areaY = math.max(0, bottomY - areaH)\n\n        setChatColor(\"panelBg\")\n        gfx.fillRect(areaX, areaY, areaW, areaH)\n        setChatColor(\"panelTop\")\n        gfx.fillRect(areaX, areaY, areaW, 1)\n\n        local rhy = areaY\n        local isHoverResize = state.mouseY and state.mouseY >= rhy and state.mouseY < rhy + CMD_RESIZE_H + 2\n        if isHoverResize or state._cmdChatDraggingResize then\n            setChatColor(\"resizeHover\")\n        else\n            setChatColor(\"resizeIdle\")\n        end\n        gfx.fillRect(areaX + math.floor(areaW / 2) - 20, rhy + 1, 40, 2)\n        state._cmdChatResizeHandle = {x = areaX, y = rhy, w = areaW, h = CMD_RESIZE_H + 2}\n\n        local headerY = areaY + CMD_RESIZE_H + 2\n        setChatColor(\"headerBg\")\n        gfx.fillRect(areaX, headerY, areaW, CMD_HEADER_H)\n        setChatColor(\"headerRule\")\n        gfx.fillRect(areaX, headerY + CMD_HEADER_H - 1, areaW, 1)\n        gfx.setFont(\"small\")\n        setChatColor(\"headerText\")\n        gfx.drawText(\"Activity\", areaX + CMD_PAD, headerY + math.floor((CMD_HEADER_H - select(2, gfx.getTextSize(\"Activity\"))) / 2))\n        local muteBtnW, muteBtnH = _touchMode and 64 or 42, _touchMode and 30 or 13\n        local muteBtnX = areaX + areaW - muteBtnW - CMD_PAD\n        local muteBtnY = headerY + math.floor((CMD_HEADER_H - muteBtnH) / 2)\n        state._cmdChatMuteBtn = {x = muteBtnX, y = muteBtnY, w = muteBtnW, h = muteBtnH}\n        local muteBtnR = _touchMode and 6 or 3\n        setChatColor(state.cmdChatMuted and \"muteActive\" or \"muteIdle\")\n        gfx.fillRoundRect(muteBtnX, muteBtnY, muteBtnW, muteBtnH, muteBtnR)\n        setChatColor(\"muteBorder\")\n        gfx.drawRoundRect(muteBtnX, muteBtnY, muteBtnW, muteBtnH, muteBtnR)\n        if state.cmdChatMuted then\n            gfx.setColor(255, 255, 255, 255)\n        else\n            setChatColor(\"headerText\")\n        end\n        local muteLabel = state.cmdChatMuted and \"Muted\" or \"Mute\"\n        local muteLabelW, muteLabelH = gfx.getTextSize(muteLabel)\n        gfx.drawText(muteLabel, muteBtnX + math.floor((muteBtnW - muteLabelW) / 2), muteBtnY + math.floor((muteBtnH - muteLabelH) / 2))\n\n        local activeAudition = state._cmdChatAuditionLoopText ~= nil or state._cmdChatAuditionQueue ~= nil\n        local statusLabel = \"Idle\"\n        if activeAudition then\n            statusLabel = state._cmdChatAuditionLoopText and \"Loop\" or \"Play\"\n        elseif state.cmdChatInputActive then\n            statusLabel = \"Input\"\n        end\n        local statusW = _touchMode and 56 or 39\n        local badgeGap = _touchMode and 6 or 5\n        local badgeR = _touchMode and 6 or 3\n        local statusX = muteBtnX - statusW - badgeGap\n        if statusX > areaX + 96 then\n            setChatColor(\"badgeBg\", 230)\n            gfx.fillRoundRect(statusX, muteBtnY, statusW, muteBtnH, badgeR)\n            setChatColor(\"badgeBorder\")\n            gfx.drawRoundRect(statusX, muteBtnY, statusW, muteBtnH, badgeR)\n            setChatColor(activeAudition and \"activityActive\" or \"activityIdle\")\n            local dotR = _touchMode and 3 or 2\n            gfx.fillCircle(statusX + dotR + 5, muteBtnY + math.floor(muteBtnH / 2), dotR)\n            setChatColor(\"headerSubtle\")\n            local sLabelW, sLabelH = gfx.getTextSize(statusLabel)\n            gfx.drawText(statusLabel, statusX + dotR * 2 + 8, muteBtnY + math.floor((muteBtnH - sLabelH) / 2))\n        end\n\n        local msgCount = #(state.cmdChatMessages or {})\n        local countLabel = tostring(msgCount)\n        local countW = select(1, gfx.getTextSize(countLabel)) + (_touchMode and 16 or 10)\n        local countX = statusX - countW - (_touchMode and 5 or 4)\n        if countX > areaX + 92 then\n            setChatColor(\"badgeBg\", 210)\n            gfx.fillRoundRect(countX, muteBtnY, countW, muteBtnH, badgeR)\n            setChatColor(\"badgeBorder\", 230)\n            gfx.drawRoundRect(countX, muteBtnY, countW, muteBtnH, badgeR)\n            setChatColor(\"headerSubtle\")\n            local cLabelW, cLabelH = gfx.getTextSize(countLabel)\n            gfx.drawText(countLabel, countX + math.floor((countW - cLabelW) / 2), muteBtnY + math.floor((muteBtnH - cLabelH) / 2))\n        end\n\n        local inputY = areaY + areaH - CMD_INPUT_H - CMD_PAD\n        local inputX = areaX + CMD_PAD\n        local inputW = areaW - CMD_PAD * 2\n        local inputR = _touchMode and 8 or 3\n        setChatColor(\"inputBg\")\n        gfx.fillRoundRect(inputX, inputY, inputW, CMD_INPUT_H, inputR)\n        setChatColor(state.cmdChatInputActive and \"inputBorderActive\" or \"inputBorder\")\n        gfx.drawRoundRect(inputX, inputY, inputW, CMD_INPUT_H, inputR)\n        state._cmdChatInputBounds = {x = inputX, y = inputY, w = inputW, h = CMD_INPUT_H}\n\n        local promptStr = \">\"\n        local promptW, promptH = gfx.getTextSize(promptStr)\n        local inputTextY = inputY + math.floor((CMD_INPUT_H - promptH) / 2)\n        setChatColor(\"prompt\")\n        if promptStr ~= \"\" then\n            gfx.drawText(promptStr, inputX + (_touchMode and 8 or 4), inputTextY)\n        end\n        local buf = state.cmdChatInputBuffer or \"\"\n        local inputPadX = _touchMode and 8 or 4\n        local textAvailW = math.max(8, inputW - inputPadX * 2 - promptW)\n        local displayBuf = buf\n        local hiddenLeft = 0\n        local cur = math.max(0, math.min(#buf, state.cmdChatInputCursor or #buf))\n        while hiddenLeft < cur and select(1, gfx.getTextSize(displayBuf)) > textAvailW do\n            hiddenLeft = hiddenLeft + 1\n            displayBuf = buf:sub(hiddenLeft + 1)\n        end\n        while #displayBuf > 0 and select(1, gfx.getTextSize(displayBuf)) > textAvailW do\n            displayBuf = displayBuf:sub(1, -2)\n        end\n        local textDrawX = inputX + inputPadX + promptW\n        local textDrawY = inputTextY\n        local charXs = {[0] = 0}\n        for ci = 1, #displayBuf do\n            charXs[ci] = select(1, gfx.getTextSize(displayBuf:sub(1, ci)))\n        end\n        state._cmdChatInputTextMetrics = {\n            x = textDrawX,\n            y = textDrawY,\n            w = textAvailW,\n            h = CMD_INPUT_H - 8,\n            hiddenLeft = hiddenLeft,\n            displayText = displayBuf,\n            charXs = charXs,\n            promptW = promptW,\n        }\n        local selA, selF = state.cmdChatSelAnchor, state.cmdChatSelFocus\n        if selA ~= nil and selF ~= nil and selA ~= selF and #displayBuf > 0 then\n            local selLo = math.max(0, math.min(selA, selF))\n            local selHi = math.min(#buf, math.max(selA, selF))\n            local visLo = math.max(0, selLo - hiddenLeft)\n            local visHi = math.min(#displayBuf, selHi - hiddenLeft)\n            if visHi > visLo then\n                setChatColor(\"inputBorderActive\", 95)\n                local x1 = textDrawX + (charXs[visLo] or select(1, gfx.getTextSize(displayBuf:sub(1, visLo))))\n                local x2 = textDrawX + (charXs[visHi] or select(1, gfx.getTextSize(displayBuf:sub(1, visHi))))\n                gfx.fillRect(x1, textDrawY, math.max(1, x2 - x1), CMD_INPUT_H - 8)\n            end\n        end\n        if #buf == 0 then\n            setChatColor(\"placeholderText\")\n            drawFittedText(\"type help, do re mi, or a command\", inputX + 8 + promptW, inputY + 4, textAvailW)\n        else\n            setChatColor(\"text\")\n            gfx.drawText(displayBuf, textDrawX, textDrawY)\n        end\n\n        if state.cmdChatInputActive then\n            local beforeCursor = buf:sub(hiddenLeft + 1, cur)\n            local cursorX = textDrawX + select(1, gfx.getTextSize(beforeCursor))\n            local blinkPhase = os.clock() - (state.cmdChatCursorResetTime or 0)\n            local blinkOn = (math.floor(blinkPhase * 1.5) % 2 == 0)\n            if blinkOn then\n                setChatColor(\"text\")\n                local curW = 1\n                local curPad = _touchMode and 10 or 4\n                gfx.fillRect(cursorX, inputY + curPad, curW, CMD_INPUT_H - curPad * 2)\n            end\n        end\n\n        local msgTop = headerY + CMD_HEADER_H + 3\n        local msgBottom = inputY - 4\n        local msgH = msgBottom - msgTop\n        state._cmdChatMessageBounds = nil\n        state._cmdChatScrollMax = 0\n        state._cmdChatScrollTrack = nil\n        if msgH > 0 then\n            state._cmdChatMessageBounds = {x = inputX, y = msgTop, w = inputW, h = msgH}\n            local msgs = state.cmdChatMessages or {}\n            local gutterW = 28\n            local msgW = inputW - gutterW - 8\n            local isDark = (state and state.darkMode) == true\n            local cacheKey = #msgs .. \"\\31\" .. msgW .. \"\\31\" .. (isDark and \"d\" or \"l\")\n                             .. \"\\31\" .. (msgs[#msgs] and (msgs[#msgs].text or \"\") or \"\")\n            local wrappedLines\n            if _chatWrappedCacheKey == cacheKey and _chatWrappedCache then\n                wrappedLines = _chatWrappedCache\n            else\n                wrappedLines = {}\n                local function pushWrappedLine(text, role, color, isFirst)\n                    local line = tostring(text or \"\")\n                    if line == \"\" then\n                        wrappedLines[#wrappedLines + 1] = {text = \"\", role = role, color = color, first = isFirst}\n                        return\n                    end\n\n                    local words = {}\n                    for w in line:gmatch(\"%S+\") do words[#words + 1] = w end\n                    if #words == 0 then\n                        wrappedLines[#wrappedLines + 1] = {text = \"\", role = role, color = color, first = isFirst}\n                        return\n                    end\n\n                    local current = words[1]\n                    for wi = 2, #words do\n                        local test = current .. \" \" .. words[wi]\n                        if select(1, gfx.getTextSize(test)) > msgW then\n                            wrappedLines[#wrappedLines + 1] = {text = current, role = role, color = color, first = isFirst}\n                            isFirst = false\n                            current = words[wi]\n                        else\n                            current = test\n                        end\n                    end\n                    wrappedLines[#wrappedLines + 1] = {text = current, role = role, color = color, first = isFirst}\n                end\n\n                local colors = chatColors()\n                for _, msg in ipairs(msgs) do\n                    local role = msg.role or \"system\"\n                    local color\n                    if role == \"user\" then\n                        color = colors.userText\n                    elseif role == \"error\" then\n                        color = colors.errorText\n                    else\n                        color = colors.systemText\n                    end\n                    local isFirst = true\n                    local fullText = msg.text or \"\"\n                    for line in (fullText .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do\n                        pushWrappedLine(line, role, color, isFirst)\n                        isFirst = false\n                    end\n                end\n                _chatWrappedCache = wrappedLines\n                _chatWrappedCacheKey = cacheKey\n            end\n\n            local visibleLines = math.floor(msgH / CMD_LINE_H)\n            local maxScroll = math.max(0, #wrappedLines - visibleLines)\n            local scrollOff = math.max(0, math.min(maxScroll, state.cmdChatScrollOffset or 0))\n            state.cmdChatScrollOffset = scrollOff\n            state._cmdChatScrollMax = maxScroll\n            state._cmdChatVisibleLines = visibleLines\n            state._cmdChatWrappedLines = #wrappedLines\n            local startLine = #wrappedLines - visibleLines - scrollOff + 1\n            local drawY = msgTop\n            if #wrappedLines == 0 then\n                setChatColor(\"headerSubtle\")\n                gfx.drawText(\"No activity yet\", inputX + 5, msgTop + 2)\n                setChatColor(\"placeholderText\")\n                drawFittedText(\"Use chat to add notes, run commands, or audition solfege.\", inputX + 5, msgTop + CMD_LINE_H + 2, inputW - 10)\n            else\n                for i = math.max(1, startLine), math.min(#wrappedLines, startLine + visibleLines - 1) do\n                    local wl = wrappedLines[i]\n                    if wl then\n                        if wl.first then\n                            local mark = wl.role == \"user\" and \"You\" or (wl.role == \"error\" and \"!\" or \"App\")\n                            if wl.role == \"user\" then\n                                setChatColor(\"userRow\", 190)\n                                gfx.fillRoundRect(inputX, drawY - 1, inputW - 7, CMD_LINE_H, 2)\n                            elseif wl.role == \"error\" then\n                                setChatColor(\"errorRow\", 190)\n                                gfx.fillRoundRect(inputX, drawY - 1, inputW - 7, CMD_LINE_H, 2)\n                            else\n                                setChatColor(\"systemRow\", 170)\n                                gfx.fillRoundRect(inputX, drawY - 1, inputW - 7, CMD_LINE_H, 2)\n                            end\n                            gfx.setColor(wl.color[1], wl.color[2], wl.color[3], 220)\n                            drawFittedText(mark, inputX + 5, drawY, gutterW - 7)\n                        end\n                        gfx.setColor(wl.color[1], wl.color[2], wl.color[3], 255)\n                        drawFittedText(wl.text, inputX + gutterW, drawY, msgW)\n                        drawY = drawY + CMD_LINE_H\n                    end\n                end\n            end\n\n            if maxScroll > 0 and visibleLines > 0 then\n                local trackX = areaX + areaW - 6\n                local trackY = msgTop\n                local trackH = msgH\n                local thumbH = math.max(8, math.floor(trackH * visibleLines / #wrappedLines))\n                local thumbY = trackY + math.floor((trackH - thumbH) * (maxScroll - scrollOff) / maxScroll)\n                state._cmdChatScrollTrack = {x = trackX - 2, y = trackY, w = 6, h = trackH, thumbY = thumbY, thumbH = thumbH}\n                setChatColor(\"scrollTrack\", 190)\n                gfx.fillRoundRect(trackX, trackY, 3, trackH, 2)\n                setChatColor(\"scrollThumb\", 230)\n                gfx.fillRoundRect(trackX, thumbY, 3, thumbH, 2)\n                if scrollOff < maxScroll then\n                    setChatColor(\"headerSubtle\", 210)\n                    gfx.drawText(\"^\", trackX - 1, trackY - 1)\n                end\n                if scrollOff > 0 then\n                    setChatColor(\"headerSubtle\", 210)\n                    gfx.drawText(\"v\", trackX - 1, trackY + trackH - CMD_LINE_H + 1)\n                end\n            end\n        end\n\n        state._cmdChatPanelBounds = {x = areaX, y = areaY, w = areaW, h = areaH}\n        gfx.setFont(\"bold\")\n        gfx.setColor(0, 0, 0, 255)\n    else\n        state._cmdChatPanelBounds = nil\n        state._cmdChatResizeHandle = nil\n        state._cmdChatInputBounds = nil\n        state._cmdChatMuteBtn = nil\n        state._cmdChatMessageBounds = nil\n        state._cmdChatScrollMax = 0\n        state._cmdChatScrollTrack = nil\n        state._cmdChatInputTextMetrics = nil\n    end\nend\n\nfunction ui.render(ctx)\n    local gfx = ctx.gfx  -- This is now the graphics adapter\n    _gfx = gfx\n    local state = ctx.state\n    local core = ctx.core\n    local isMacDesktop = ctx.isMacDesktop == true\n    state._lyricStepInputBounds = nil\n\n    -- Update screen dimensions from graphics adapter (only when changed to avoid per-frame bridge calls)\n    if gfx.getScreenWidth then local _w = gfx.getScreenWidth(); if _w ~= SCREEN_W then SCREEN_W = _w end end\n    if gfx.getScreenHeight then local _h = gfx.getScreenHeight(); if _h ~= SCREEN_H then SCREEN_H = _h end end\n\n    -- Cache font heights once per frame to avoid repeated getTextSize(\"X\") calls\n    gfx.setFont(\"normal\"); do local _, h = gfx.getTextSize(\"X\"); _fontH.normal = h end\n    gfx.setFont(\"bold\");   do local _, h = gfx.getTextSize(\"X\"); _fontH.bold = h end\n    gfx.setFont(\"small\");  do local _, h = gfx.getTextSize(\"X\"); _fontH.small = h end\n    gfx.setFont(\"normal\")\n\n    gfx.setDarkMode(state.darkMode == true)\n    gfx.clear(\"white\")\n    drawThemeRect(gfx, state, \"appBg\", 0, 0, SCREEN_W, SCREEN_H)\n\n    -- Welcome screen\n    if ctx.showWelcomeScreen then\n        local W = SCREEN_W\n        local H = SCREEN_H\n        local recentFiles = ctx.welcomeRecentFiles or {}\n        local btns = {}\n        local hasRecent = #recentFiles > 0\n        local isWebPlatform = ctx.platformName == \"web\"\n\n        local contentTop = isMacDesktop and 60 or (4 + _safeAreaTop)\n\n        if hasRecent then\n            -- Portrait: single-column stacked layout; landscape: two-column side-by-side\n            local portrait = isPortraitLayout()\n            local panelGap = 8\n            local leftW = portrait and (W - 8) or math.floor(W * 0.38)\n            local rightW = portrait and (W - 8) or (W - leftW - panelGap - 8)\n            local leftX = 4\n            local rightX = portrait and 4 or (leftX + leftW + panelGap)\n            local panelTop = contentTop + 4\n            local panelBottom = H - 4\n\n            -- Left panel: action buttons\n            local btnW = leftW - 12\n            local btnH = _touchMode and 56 or (portrait and 22 or 24)\n            local btnGap = _touchMode and 14 or (portrait and 4 or 6)\n            local btnX = leftX + 6\n            local curY = panelTop\n\n            gfx.setFont(\"bold\")\n            local _, boldH = gfx.getTextSize(\"X\")\n            setThemeColor(gfx, state, \"text\")\n            drawCenteredText(gfx, \"Solfege\", curY, leftX, leftX + leftW)\n            curY = curY + boldH + 8\n\n            gfx.setFont(\"normal\")\n\n            local btnR = _touchMode and 10 or 5\n            -- New Project (primary — filled)\n            local newBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"new\"}\n            table.insert(btns, newBtn)\n            setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n            gfx.fillRoundRect(btnX, curY, btnW, btnH, btnR)\n            gfx.setDrawMode(\"fillWhite\")\n            drawCenteredText(gfx, \"New Project\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n            gfx.setDrawMode(\"copy\")\n            setThemeColor(gfx, state, \"text\")\n            curY = curY + btnH + btnGap\n\n            -- Browse Templates (outline)\n            local templatesBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"templates\"}\n            table.insert(btns, templatesBtn)\n            setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n            gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR)\n            setThemeColor(gfx, state, \"text\")\n            drawCenteredText(gfx, \"Templates\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n            curY = curY + btnH + btnGap\n\n            if isWebPlatform then\n                -- Open File (outline — web file picker)\n                local openFileBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"open_file\"}\n                table.insert(btns, openFileBtn)\n                setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR)\n                setThemeColor(gfx, state, \"text\")\n                drawCenteredText(gfx, \"Open File\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n                curY = curY + btnH + btnGap\n            end\n\n            do\n                local showOneDrive = not isWebPlatform or ctx.webOneDriveAvailable\n                if showOneDrive then\n                    -- Connect OneDrive (outline)\n                    local oneDriveLabel\n                    if ctx.oneDriveRoot and ctx.oneDriveRoot ~= \"\" then\n                        oneDriveLabel = isWebPlatform and \"Disconnect OneDrive\" or \"Reconnect OneDrive\"\n                    else\n                        oneDriveLabel = isWebPlatform and \"Sign in to OneDrive\" or \"Connect OneDrive\"\n                    end\n                    local oneDriveBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"onedrive\"}\n                    table.insert(btns, oneDriveBtn)\n                    setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                    gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR)\n                    setThemeColor(gfx, state, \"text\")\n                    drawCenteredText(gfx, oneDriveLabel, curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n                    curY = curY + btnH + btnGap\n                end\n\n                if not isWebPlatform then\n                    -- Import DOCX (outline)\n                    local importDocxBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"import_docx\"}\n                    table.insert(btns, importDocxBtn)\n                    setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                    gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR)\n                    setThemeColor(gfx, state, \"text\")\n                    drawCenteredText(gfx, \"Import DOCX\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n                    curY = curY + btnH + 6\n                end\n\n                -- Status line\n                setAbsoluteColor(gfx, state, 120, 128, 148, 255)\n                if ctx.oneDriveRoot and ctx.oneDriveRoot ~= \"\" then\n                    local rootName = ctx.oneDriveRoot:match(\"([^/\\\\]+)$\") or ctx.oneDriveRoot\n                    gfx.drawText(\"OneDrive: \" .. tostring(rootName), btnX, curY)\n                end\n                setThemeColor(gfx, state, \"text\")\n            end\n\n            -- Recent projects panel (below buttons in portrait, right column in landscape)\n            gfx.setFont(\"bold\")\n            local headerY = portrait and (curY + 8) or panelTop\n            if portrait then panelBottom = H - 4 end\n            drawCenteredText(gfx, \"Recent Projects\", headerY, rightX, rightX + rightW)\n            local _, headerTextH = gfx.getTextSize(\"Recent Projects\")\n            gfx.setFont(\"normal\")\n\n            -- Subtle separator line\n            local sepY = headerY + headerTextH + 2\n            setAbsoluteColor(gfx, state, 192, 198, 212, 255)\n            gfx.drawLine(rightX, sepY, rightX + rightW, sepY)\n            setThemeColor(gfx, state, \"text\")\n\n            local itemH = _touchMode and 52 or 22\n            local itemGap = _touchMode and 10 or 2\n            local itemW = rightW\n            local itemX = rightX\n            local itemY = sepY + 4\n            local listBottom = panelBottom\n\n            local visibleCount = math.max(1, math.floor((listBottom - itemY) / (itemH + itemGap)))\n            state._welcomeVisibleCount = visibleCount\n\n            local firstVisible = state._welcomeFirstVisible or 1\n            local maxFirst = math.max(1, #recentFiles - visibleCount + 1)\n            firstVisible = math.max(1, math.min(firstVisible, maxFirst))\n            state._welcomeFirstVisible = firstVisible\n            local lastVisible = math.min(#recentFiles, firstVisible + visibleCount - 1)\n\n            drawListViewportStatus(gfx, firstVisible, lastVisible, #recentFiles, itemY, listBottom, headerY + 2)\n\n            -- Scrollbar\n            local total = #recentFiles\n            if total > visibleCount then\n                local sbW = 4\n                local sbPad = 4\n                local sbX = itemX + itemW - sbW - 1\n                local trackH = listBottom - itemY\n                local availH = trackH - sbPad * 2\n                local thumbH = math.max(sbW * 4, math.floor(availH * visibleCount / total))\n                local thumbY = itemY + sbPad + math.floor((availH - thumbH) * (firstVisible - 1) / math.max(1, total - visibleCount))\n                setAbsoluteColor(gfx, state, 172, 179, 194, 180)\n                if isMacDesktop then\n                    gfx.fillRoundRect(sbX, thumbY, sbW, thumbH, sbW / 2)\n                else\n                    gfx.fillRect(sbX, thumbY, sbW, thumbH)\n                end\n                setThemeColor(gfx, state, \"text\")\n                state._welcomeScrollbar = {\n                    hitX    = sbX - 4,\n                    hitW    = sbW + 8,\n                    trackTop = itemY + sbPad,\n                    availH  = availH,\n                    thumbH  = thumbH,\n                    thumbY  = thumbY,\n                    total   = total,\n                    visibleCount = visibleCount,\n                    listTop  = itemY,\n                    listBottom = listBottom,\n                }\n            else\n                state._welcomeScrollbar = nil\n            end\n\n            for i = firstVisible, lastVisible do\n                local f = recentFiles[i]\n                local iy = itemY + (i - firstVisible) * (itemH + itemGap)\n                local mx = state.mouseX or -1\n                local my = state.mouseY or -1\n                local hovered = mx >= itemX and mx < itemX + itemW and my >= iy and my < iy + itemH\n\n                -- Folder button on the right side\n                local folderLabel = \"\"\n                if f.folder and f.folder ~= \"\" then\n                    folderLabel = f.folder:match(\"([^/]+)$\") or \"\"\n                end\n                local folderBtnW = 0\n                if folderLabel ~= \"\" then\n                    local tw, _ = gfx.getTextSize(folderLabel)\n                    folderBtnW = tw + 14\n                end\n                local mainBtnW = itemW - (folderBtnW > 0 and folderBtnW + 4 or 0)\n\n                if hovered then\n                    setAbsoluteColor(gfx, state, 226, 233, 246, 255)\n                    gfx.fillRoundRect(itemX, iy, mainBtnW, itemH, 4)\n                    setThemeColor(gfx, state, \"text\")\n                end\n\n                local btn = {x = itemX, y = iy, w = mainBtnW, h = itemH, action = \"open\", path = f.path}\n                table.insert(btns, btn)\n                gfx.drawRoundRect(itemX, iy, mainBtnW, itemH, 4)\n                gfx.drawText(f.name, itemX + 10, iy + getDropdownTextYOffset(gfx, itemH))\n\n                if folderBtnW > 0 then\n                    local fbX = itemX + mainBtnW + 4\n                    local folderBtn = {x = fbX, y = iy, w = folderBtnW, h = itemH, action = \"open_folder\", path = f.folder}\n                    table.insert(btns, folderBtn)\n                    setAbsoluteColor(gfx, state, 120, 128, 148, 255)\n                    gfx.drawRoundRect(fbX, iy, folderBtnW, itemH, 4)\n                    gfx.drawText(folderLabel, fbX + 7, iy + getDropdownTextYOffset(gfx, itemH))\n                    setThemeColor(gfx, state, \"text\")\n                end\n            end\n        else\n            -- No recent files: centered single-column layout\n            local btnW = _touchMode and math.min(340, math.max(240, W - 40)) or 180\n            local btnH = _touchMode and 56 or 28\n            local btnGap = _touchMode and 14 or 8\n            local btnX = math.floor((W - btnW) / 2)\n\n            gfx.setFont(\"bold\")\n            local _, boldH = gfx.getTextSize(\"X\")\n            local titleY = contentTop + 20\n            setThemeColor(gfx, state, \"text\")\n            drawCenteredText(gfx, \"Solfege\", titleY)\n            gfx.setFont(\"normal\")\n\n            local subtitleY = titleY + boldH + 4\n            setAbsoluteColor(gfx, state, 120, 128, 148, 255)\n            local subtitleText = isWebPlatform\n                and \"Create a new project or drop a file to open\"\n                or \"Create a new project to get started\"\n            drawCenteredText(gfx, subtitleText, subtitleY)\n            setThemeColor(gfx, state, \"text\")\n\n            local curY = subtitleY + 20\n\n            local btnR2 = _touchMode and 12 or 6\n            -- New Project (primary)\n            local newBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"new\"}\n            table.insert(btns, newBtn)\n            setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n            gfx.fillRoundRect(btnX, curY, btnW, btnH, btnR2)\n            gfx.setDrawMode(\"fillWhite\")\n            drawCenteredText(gfx, \"New Project\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n            gfx.setDrawMode(\"copy\")\n            setThemeColor(gfx, state, \"text\")\n            curY = curY + btnH + btnGap\n\n            -- Templates (outline)\n            local templatesBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"templates\"}\n            table.insert(btns, templatesBtn)\n            setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n            gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR2)\n            setThemeColor(gfx, state, \"text\")\n            drawCenteredText(gfx, \"Browse Templates\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n            curY = curY + btnH + btnGap\n\n            if isWebPlatform then\n                -- Open File (outline — web file picker)\n                local openFileBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"open_file\"}\n                table.insert(btns, openFileBtn)\n                setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR2)\n                setThemeColor(gfx, state, \"text\")\n                drawCenteredText(gfx, \"Open File\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n                curY = curY + btnH + btnGap\n            end\n\n            do\n                local showOneDrive = not isWebPlatform or ctx.webOneDriveAvailable\n                if showOneDrive then\n                    -- Connect OneDrive (outline)\n                    local oneDriveLabel\n                    if ctx.oneDriveRoot and ctx.oneDriveRoot ~= \"\" then\n                        oneDriveLabel = isWebPlatform and \"Disconnect OneDrive\" or \"Reconnect OneDrive\"\n                    else\n                        oneDriveLabel = isWebPlatform and \"Sign in to OneDrive\" or \"Connect OneDrive\"\n                    end\n                    local oneDriveBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"onedrive\"}\n                    table.insert(btns, oneDriveBtn)\n                    setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                    gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR2)\n                    setThemeColor(gfx, state, \"text\")\n                    drawCenteredText(gfx, oneDriveLabel, curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n                    curY = curY + btnH + btnGap\n                end\n\n                if not isWebPlatform then\n                    -- Import DOCX (outline)\n                    local importDocxBtn = {x = btnX, y = curY, w = btnW, h = btnH, action = \"import_docx\"}\n                    table.insert(btns, importDocxBtn)\n                    setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                    gfx.drawRoundRect(btnX, curY, btnW, btnH, btnR2)\n                    setThemeColor(gfx, state, \"text\")\n                    drawCenteredText(gfx, \"Import DOCX Lyrics\", curY + getDropdownTextYOffset(gfx, btnH), btnX, btnX + btnW)\n                    curY = curY + btnH + 8\n                end\n\n                if ctx.oneDriveRoot and ctx.oneDriveRoot ~= \"\" then\n                    setAbsoluteColor(gfx, state, 120, 128, 148, 255)\n                    local rootName = ctx.oneDriveRoot:match(\"([^/\\\\]+)$\") or ctx.oneDriveRoot\n                    drawCenteredText(gfx, \"OneDrive: \" .. tostring(rootName), curY)\n                    setThemeColor(gfx, state, \"text\")\n                end\n            end\n        end\n\n        gfx.setFont(\"normal\")\n        setAbsoluteColor(gfx, state, 160, 166, 180, 255)\n        local versionText = \"v\" .. APP_VERSION\n        local vw, vh = gfx.getTextSize(versionText)\n        gfx.drawText(versionText, W - vw - 6, H - vh - 4)\n        setThemeColor(gfx, state, \"text\")\n\n        state._welcomeBtns = btns\n        return\n    end\n\n    if ctx.showRecordingScreen then\n        gfx.setFont(\"bold\")\n        drawMenuTitle(gfx, \"RECORD SAMPLE\", 20, isMacDesktop)\n\n        if ctx.isRecording then\n            local elapsed = (getCurrentTimeMilliseconds(ctx) - ctx.recordingStartTime) / 1000.0\n            local progress = math.min(elapsed / ctx.recordingDuration, 1.0)\n\n            local barW = math.min(300, SCREEN_W - 40)\n            local barX = math.floor((SCREEN_W - barW) / 2)\n            if isMacDesktop then\n                gfx.drawRoundRect(barX, 100, barW, 40, 6)\n                if progress > 0.01 then\n                    gfx.fillRoundRect(barX + 2, 102, math.max(8, math.floor((barW - 4) * progress)), 36, 5)\n                end\n            else\n                gfx.drawRect(barX, 100, barW, 40)\n                gfx.fillRect(barX + 2, 102, ((barW - 4) * progress), 36)\n            end\n\n            drawCenteredText(gfx, \"Recording...\", 60)\n            gfx.setFont(\"normal\")\n            drawCenteredText(gfx, \"Sing or play a note!\", 160)\n        else\n            drawCenteredText(gfx, \"Ready to Record\", 80)\n            gfx.setFont(\"normal\")\n            drawCenteredText(gfx, \"(Sing steady note for 3 sec)\", 180)\n        end\n\n        return\n    end\n\n    -- Ear training screen\n    if state.earTrainingMode then\n        local W = SCREEN_W\n        local H = SCREEN_H\n        local btns = {}\n        local q = state.earTrainingQuestion\n        local revealed = state.earTrainingRevealed\n        local score = state.earTrainingScore or {correct = 0, total = 0}\n        local contentTop = isMacDesktop and 38 or (4 + _safeAreaTop)\n        local exerciseType = state.earTrainingExercise or \"interval\"\n\n        local EXERCISE_LABELS = {\n            interval = \"Intervals\",\n            chord = \"Chords\",\n            scale = \"Scales\",\n            dictation = \"Dictation\",\n        }\n        local DIFFICULTY_LABELS = {\"Easy\", \"Medium\", \"Hard\"}\n\n        -- Header bar\n        gfx.setFont(\"bold\")\n        local backBtn = {x = 4, y = contentTop, w = 50, h = 22, action = \"back\"}\n        table.insert(btns, backBtn)\n        setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n        gfx.drawRoundRect(4, contentTop, 50, 22, 4)\n        setThemeColor(gfx, state, \"text\")\n        gfx.drawText(\"< Back\", 10, contentTop + 3)\n\n        drawCenteredText(gfx, \"Ear Training: \" .. (EXERCISE_LABELS[exerciseType] or exerciseType), contentTop + 2)\n\n        -- Score display\n        gfx.setFont(\"normal\")\n        local scoreText = score.correct .. \"/\" .. score.total\n        local stw = gfx.getTextSize(scoreText)\n        gfx.drawText(scoreText, W - stw - 8, contentTop + 4)\n\n        local headerBottom = contentTop + 28\n\n        -- Difficulty tabs — scale down in portrait\n        local diffY = headerBottom + 2\n        local tabW = isPortraitLayout() and math.max(36, math.floor((W - 24) / 3)) or 54\n        local tabH = 18\n        local tabGap = isPortraitLayout() and 2 or 4\n        local totalTabW = tabW * 3 + tabGap * 2\n        local tabStartX = math.floor((W - totalTabW) / 2)\n        for i = 1, 3 do\n            local tx = tabStartX + (i - 1) * (tabW + tabGap)\n            local isActive = (state.earTrainingDifficulty or 1) == i\n            local btn = {x = tx, y = diffY, w = tabW, h = tabH, action = \"difficulty\", value = i}\n            table.insert(btns, btn)\n            if isActive then\n                setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n                gfx.fillRoundRect(tx, diffY, tabW, tabH, 4)\n                gfx.setDrawMode(\"fillWhite\")\n                drawCenteredText(gfx, DIFFICULTY_LABELS[i], diffY + 2, tx, tx + tabW)\n                gfx.setDrawMode(\"copy\")\n                setThemeColor(gfx, state, \"text\")\n            else\n                setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                gfx.drawRoundRect(tx, diffY, tabW, tabH, 4)\n                setThemeColor(gfx, state, \"text\")\n                drawCenteredText(gfx, DIFFICULTY_LABELS[i], diffY + 2, tx, tx + tabW)\n            end\n        end\n\n        -- Exercise type tabs — scale down in portrait\n        local exY = diffY + tabH + 4\n        local exTypes = {\"interval\", \"chord\", \"scale\", \"dictation\"}\n        local exTabW = isPortraitLayout() and math.max(44, math.floor((W - 20) / 4)) or 70\n        local exTotalW = exTabW * 4 + tabGap * 3\n        local exStartX = math.floor((W - exTotalW) / 2)\n        for i, et in ipairs(exTypes) do\n            local tx = exStartX + (i - 1) * (exTabW + tabGap)\n            local isActive = exerciseType == et\n            local btn = {x = tx, y = exY, w = exTabW, h = tabH, action = \"exercise_type\", value = et}\n            table.insert(btns, btn)\n            if isActive then\n                setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n                gfx.fillRoundRect(tx, exY, exTabW, tabH, 4)\n                gfx.setDrawMode(\"fillWhite\")\n                drawCenteredText(gfx, EXERCISE_LABELS[et], exY + 2, tx, tx + exTabW)\n                gfx.setDrawMode(\"copy\")\n                setThemeColor(gfx, state, \"text\")\n            else\n                setAbsoluteColor(gfx, state, 142, 151, 170, 255)\n                gfx.drawRoundRect(tx, exY, exTabW, tabH, 4)\n                setThemeColor(gfx, state, \"text\")\n                drawCenteredText(gfx, EXERCISE_LABELS[et], exY + 2, tx, tx + exTabW)\n            end\n        end\n\n        local questionTop = exY + tabH + 12\n\n        if q then\n            -- Prompt\n            gfx.setFont(\"bold\")\n            drawCenteredText(gfx, q.prompt or \"\", questionTop)\n            gfx.setFont(\"normal\")\n\n            -- Replay button\n            local replayW = 60\n            local replayH = 22\n            local replayX = math.floor((W - replayW) / 2)\n            local replayY = questionTop + 20\n            local replayBtn = {x = replayX, y = replayY, w = replayW, h = replayH, action = \"replay\"}\n            table.insert(btns, replayBtn)\n            setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n            gfx.fillRoundRect(replayX, replayY, replayW, replayH, 5)\n            gfx.setDrawMode(\"fillWhite\")\n            drawCenteredText(gfx, \"Replay\", replayY + 3, replayX, replayX + replayW)\n            gfx.setDrawMode(\"copy\")\n            setThemeColor(gfx, state, \"text\")\n\n            local optionsTop = replayY + replayH + 14\n\n            if q.options then\n                -- Multiple choice answers\n                local optW = math.min(160, W - 40)\n                local optH = 28\n                local optGap = 6\n                local totalOptH = #q.options * (optH + optGap) - optGap\n                local optStartY = optionsTop\n                local optX = math.floor((W - optW) / 2)\n\n                for i, opt in ipairs(q.options) do\n                    local oy = optStartY + (i - 1) * (optH + optGap)\n                    local btn = {x = optX, y = oy, w = optW, h = optH, action = \"option\", index = i, label = opt}\n                    table.insert(btns, btn)\n\n                    local isCorrect = opt == q.answer\n                    local isSelected = state.earTrainingSelectedOption == i\n\n                    if revealed then\n                        if isCorrect then\n                            setAbsoluteColor(gfx, state, 34, 139, 34, 255)\n                            gfx.fillRoundRect(optX, oy, optW, optH, 5)\n                            gfx.setDrawMode(\"fillWhite\")\n                            drawCenteredText(gfx, i .. \". \" .. opt, oy + 6, optX, optX + optW)\n                            gfx.setDrawMode(\"copy\")\n                            setThemeColor(gfx, state, \"text\")\n                        elseif isSelected then\n                            setAbsoluteColor(gfx, state, 205, 55, 55, 255)\n                            gfx.fillRoundRect(optX, oy, optW, optH, 5)\n                            gfx.setDrawMode(\"fillWhite\")\n                            drawCenteredText(gfx, i .. \". \" .. opt, oy + 6, optX, optX + optW)\n                            gfx.setDrawMode(\"copy\")\n                            setThemeColor(gfx, state, \"text\")\n                        else\n                            setAbsoluteColor(gfx, state, 180, 180, 180, 255)\n                            gfx.drawRoundRect(optX, oy, optW, optH, 5)\n                            setThemeColor(gfx, state, \"text\")\n                            drawCenteredText(gfx, i .. \". \" .. opt, oy + 6, optX, optX + optW)\n                        end\n                    else\n                        local mx = state.mouseX or -1\n                        local my = state.mouseY or -1\n                        local hovered = mx >= optX and mx < optX + optW and my >= oy and my < oy + optH\n                        if hovered then\n                            setAbsoluteColor(gfx, state, 55, 111, 205, 40)\n                            gfx.fillRoundRect(optX, oy, optW, optH, 5)\n                        end\n                        setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n                        gfx.drawRoundRect(optX, oy, optW, optH, 5)\n                        setThemeColor(gfx, state, \"text\")\n                        drawCenteredText(gfx, i .. \". \" .. opt, oy + 6, optX, optX + optW)\n                    end\n                end\n\n                -- Answer detail\n                if revealed and q.answerDetail then\n                    local detailY = optStartY + totalOptH + 10\n                    setAbsoluteColor(gfx, state, 100, 100, 100, 255)\n                    drawCenteredText(gfx, q.answerDetail, detailY)\n                    setThemeColor(gfx, state, \"text\")\n                end\n            else\n                -- Dictation mode: text input\n                local inputW = math.min(260, W - 40)\n                local inputH = 24\n                local inputX = math.floor((W - inputW) / 2)\n                local inputY = optionsTop\n\n                gfx.drawRoundRect(inputX, inputY, inputW, inputH, 4)\n                local inputText = state.earTrainingDictationInput or \"\"\n                if #inputText > 0 then\n                    gfx.drawText(inputText, inputX + 6, inputY + 4)\n                else\n                    setAbsoluteColor(gfx, state, 160, 160, 160, 255)\n                    gfx.drawText(\"Type solfege: do re mi ...\", inputX + 6, inputY + 4)\n                    setThemeColor(gfx, state, \"text\")\n                end\n\n                if not revealed then\n                    local submitW = 60\n                    local submitH = 22\n                    local submitX = math.floor((W - submitW) / 2)\n                    local submitY = inputY + inputH + 8\n                    local submitBtn = {x = submitX, y = submitY, w = submitW, h = submitH, action = \"submit_dictation\"}\n                    table.insert(btns, submitBtn)\n                    setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n                    gfx.fillRoundRect(submitX, submitY, submitW, submitH, 5)\n                    gfx.setDrawMode(\"fillWhite\")\n                    drawCenteredText(gfx, \"Check\", submitY + 3, submitX, submitX + submitW)\n                    gfx.setDrawMode(\"copy\")\n                    setThemeColor(gfx, state, \"text\")\n                end\n\n                if revealed then\n                    local resultY = inputY + inputH + 8\n                    local allCorrect = (state._earTrainingDictationCorrect or 0) == #(q.answerSolfege or {})\n                    if allCorrect then\n                        setAbsoluteColor(gfx, state, 34, 139, 34, 255)\n                        drawCenteredText(gfx, \"Correct!\", resultY)\n                    else\n                        setAbsoluteColor(gfx, state, 205, 55, 55, 255)\n                        local got = state._earTrainingDictationCorrect or 0\n                        local total = #(q.answerSolfege or {})\n                        drawCenteredText(gfx, got .. \"/\" .. total .. \" notes correct\", resultY)\n                    end\n                    setThemeColor(gfx, state, \"text\")\n                    drawCenteredText(gfx, \"Answer: \" .. (q.answer or \"\"), resultY + 16)\n                end\n            end\n\n            -- Next button (when revealed)\n            if revealed then\n                local nextW = 80\n                local nextH = 26\n                local nextX = math.floor((W - nextW) / 2)\n                local nextY = H - 36\n                local nextBtn = {x = nextX, y = nextY, w = nextW, h = nextH, action = \"next\"}\n                table.insert(btns, nextBtn)\n                setAbsoluteColor(gfx, state, 55, 111, 205, 255)\n                gfx.fillRoundRect(nextX, nextY, nextW, nextH, 5)\n                gfx.setDrawMode(\"fillWhite\")\n                drawCenteredText(gfx, \"Next\", nextY + 5, nextX, nextX + nextW)\n                gfx.setDrawMode(\"copy\")\n                setThemeColor(gfx, state, \"text\")\n            end\n        end\n\n        state._earTrainingBtns = btns\n        return\n    end\n\n    -- Template browser screen\n    if state.showingTemplateBrowser then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        gfx.setFont(\"bold\")\n        if isMacDesktop then\n            gfx.drawText(\"< Back\", 10, 10)\n        end\n        drawMenuTitle(gfx, \"TEMPLATES\", 10 + menuSafeTop, isMacDesktop)\n        gfx.setFont(\"normal\")\n\n        -- Category selector\n        if ctx.getTemplateCategories then\n            local categories = ctx.getTemplateCategories()\n            local categoryName = categories[state.selectedTemplateCategory] or \"All\"\n            gfx.drawText(\"Category: \" .. categoryName, 20, 34 + menuSafeTop)\n        end\n\n        -- Template list\n        local startY = 52 + menuSafeTop\n        local instructY = SCREEN_H - 36\n        local visibleCount = math.floor((instructY - startY) / LIST_ITEM_STRIDE)\n\n        if ctx.getTemplateList then\n            local templates = ctx.getTemplateList()\n            local totalTemplates = #templates\n            -- Count indicator top-right (only when list is clipped)\n            gfx.setFont(\"normal\")\n            local maxFirstVisible = math.max(1, totalTemplates - visibleCount + 1)\n            local firstVisible = math.max(1, state.selectedTemplateIndex - visibleCount + 1)\n            firstVisible = math.min(firstVisible, maxFirstVisible)\n            local lastVisible = math.min(totalTemplates, firstVisible + visibleCount - 1)\n\n            drawListViewportStatus(gfx, firstVisible, lastVisible, totalTemplates, startY, instructY, 34)\n\n            for i = firstVisible, lastVisible do\n                local template = templates[i]\n                if template then\n                    local y = startY + (i - firstVisible) * LIST_ITEM_STRIDE\n                    if state.selectedTemplateIndex == i then\n                        if isMacDesktop then\n                            gfx.fillRoundRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4, 4)\n                        else\n                            gfx.fillRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4)\n                        end\n                        gfx.setDrawMode(\"fillWhite\")\n                    end\n\n                    gfx.drawText(template.name, 20, y)\n\n                    gfx.setDrawMode(\"copy\")\n                end\n            end\n        end\n\n        -- Instructions\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n\n        -- Show template preview if one is selected\n        if ctx.getSelectedTemplate then\n            local template = ctx.getSelectedTemplate()\n            if template then\n                gfx.setFont(\"normal\")\n                local descY = instructY + 8\n\n                if ctx.solfegeNotes then\n                    local preview = getTemplatePreview(template, ctx.solfegeNotes, 12)\n                    if preview then\n                        gfx.drawText(\"Preview: \" .. preview, 20, descY)\n                    end\n                end\n            end\n        end\n\n\n        _drawCmdChatPanel(gfx, state)\n        return\n    end\n\n    if state.showingModeSelect then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        gfx.setFont(\"bold\")\n        if isMacDesktop then\n            gfx.drawText(\"< Back\", 10, 10)\n        end\n        drawMenuTitle(gfx, \"SETTINGS\", 10 + menuSafeTop, isMacDesktop)\n        gfx.setFont(\"normal\")\n\n        local modes = ctx.getModeList and ctx.getModeList() or {}\n\n        -- Icon grid: 2 columns normally, 1 column in narrow portrait\n        local cols       = (isPortraitLayout() and SCREEN_W < 280) and 1 or 2\n        local rows       = math.ceil(#modes / cols)\n        local startY     = 38 + menuSafeTop\n        local gridH      = SCREEN_H - startY - 32\n        local cellW      = math.floor(SCREEN_W / cols)\n        local cellH      = math.floor(gridH / rows)\n        local iconSize   = 14\n\n        -- Icon drawing helpers (all coords relative to icon top-left cx,cy)\n        local function drawIcon(name, cx, cy, sel)\n            local x, y = cx, cy\n            local s = iconSize\n            if name == \"sing\" then\n                -- mic: rounded rect body + line stand\n                gfx.fillRoundRect(x+4, y, s-8, s-4, 3)\n                gfx.drawLine(x+2, y+s-4, x+s-2, y+s-4)\n                gfx.drawLine(x+s//2, y+s-4, x+s//2, y+s)\n            elseif name == \"edit\" then\n                -- pencil: diagonal line + nib\n                gfx.drawLine(x+2, y+s-2, x+s-2, y+2)\n                gfx.drawLine(x+2, y+s-2, x+5, y+s-2)\n                gfx.drawLine(x+2, y+s-2, x+2, y+s-5)\n            elseif name == \"sequences\" then\n                -- 3 horizontal bars\n                gfx.fillRect(x+1, y+2,    s-2, 2)\n                gfx.fillRect(x+1, y+6,    s-2, 2)\n                gfx.fillRect(x+1, y+10,   s-2, 2)\n            elseif name == \"templates\" then\n                -- grid of 4 squares\n                gfx.drawRect(x+1,   y+1,   5, 5)\n                gfx.drawRect(x+8,   y+1,   5, 5)\n                gfx.drawRect(x+1,   y+8,   5, 5)\n                gfx.drawRect(x+8,   y+8,   5, 5)\n            elseif name == \"import_midi\" then\n                -- arrow pointing right into a box\n                gfx.drawRect(x+6, y+2, s-7, s-4)\n                gfx.drawLine(x+1, y+s//2, x+5, y+s//2)\n                gfx.drawLine(x+3, y+s//2-2, x+5, y+s//2)\n                gfx.drawLine(x+3, y+s//2+2, x+5, y+s//2)\n            elseif name == \"import_xml\" then\n                -- arrow into box with < > on box face\n                gfx.drawRect(x+6, y+2, s-7, s-4)\n                gfx.drawLine(x+1, y+s//2, x+5, y+s//2)\n                gfx.drawLine(x+3, y+s//2-2, x+5, y+s//2)\n                gfx.drawLine(x+3, y+s//2+2, x+5, y+s//2)\n                gfx.drawLine(x+8, y+5, x+7, y+s//2)\n                gfx.drawLine(x+7, y+s//2, x+8, y+s-5)\n                gfx.drawLine(x+11, y+5, x+12, y+s//2)\n                gfx.drawLine(x+12, y+s//2, x+11, y+s-5)\n            elseif name == \"import_lyrics\" then\n                -- arrow into box with text lines\n                gfx.drawRect(x+6, y+2, s-7, s-4)\n                gfx.drawLine(x+1, y+s//2, x+5, y+s//2)\n                gfx.drawLine(x+3, y+s//2-2, x+5, y+s//2)\n                gfx.drawLine(x+3, y+s//2+2, x+5, y+s//2)\n                gfx.drawLine(x+8, y+5, x+11, y+5)\n                gfx.drawLine(x+8, y+8, x+11, y+8)\n                gfx.drawLine(x+8, y+11, x+10, y+11)\n            elseif name == \"export_xml\" then\n                -- arrow pointing right out of box\n                gfx.drawRect(x, y+2, s-7, s-4)\n                gfx.drawLine(x+s-5, y+s//2, x+s-1, y+s//2)\n                gfx.drawLine(x+s-3, y+s//2-2, x+s-1, y+s//2)\n                gfx.drawLine(x+s-3, y+s//2+2, x+s-1, y+s//2)\n            elseif name == \"export_lyrics\" then\n                -- arrow out of box with text lines\n                gfx.drawRect(x, y+2, s-7, s-4)\n                gfx.drawLine(x+s-5, y+s//2, x+s-1, y+s//2)\n                gfx.drawLine(x+s-3, y+s//2-2, x+s-1, y+s//2)\n                gfx.drawLine(x+s-3, y+s//2+2, x+s-1, y+s//2)\n                gfx.drawLine(x+2, y+5, x+5, y+5)\n                gfx.drawLine(x+2, y+8, x+5, y+8)\n                gfx.drawLine(x+2, y+11, x+4, y+11)\n            elseif name == \"export_as\" then\n                -- arrow out of box + small clock dots\n                gfx.drawRect(x, y+2, s-7, s-4)\n                gfx.drawLine(x+s-5, y+s//2, x+s-1, y+s//2)\n                gfx.drawLine(x+s-3, y+s//2-2, x+s-1, y+s//2)\n                gfx.drawLine(x+s-3, y+s//2+2, x+s-1, y+s//2)\n                gfx.fillRect(x+s-5, y+1, 2, 2)\n            elseif name == \"midi\" then\n                -- Piano keys: outline rect + 2 vertical dividers\n                gfx.drawRect(x+1, y+3, s-2, s-5)\n                gfx.drawLine(x+1 + (s-2)//3,     y+3, x+1 + (s-2)//3,     y+s-3)\n                gfx.drawLine(x+1 + 2*(s-2)//3,   y+3, x+1 + 2*(s-2)//3,   y+s-3)\n            elseif name == \"prefs\" then\n                -- gear: circle + 4 teeth\n                gfx.drawCircle(x+s//2, y+s//2, 3)\n                gfx.drawLine(x+s//2, y+1,   x+s//2, y+4)\n                gfx.drawLine(x+s//2, y+s-4, x+s//2, y+s-1)\n                gfx.drawLine(x+1,    y+s//2, x+4,   y+s//2)\n                gfx.drawLine(x+s-4,  y+s//2, x+s-1, y+s//2)\n            elseif name == \"shape\" then\n                -- triangle + circle side by side\n                gfx.drawLine(x+1, y+s-2, x+5, y+2)\n                gfx.drawLine(x+5, y+2,   x+9, y+s-2)\n                gfx.drawLine(x+1, y+s-2, x+9, y+s-2)\n                gfx.drawCircle(x+12, y+s//2, 3)\n            end\n        end\n\n        local iconNames = { \"sing\", \"edit\", \"sequences\",\n                            \"import_midi\", \"import_xml\", \"import_lyrics\", \"export_lyrics\", \"export_xml\", \"export_as\",\n                            \"prefs\", \"shape\", \"midi\", \"midi\", \"midi\" }\n\n        for i = 1, #modes do\n            local mode  = modes[i]\n            local col   = (i - 1) % cols        -- 0 or 1\n            local row   = math.floor((i-1) / cols)\n            local cellX = col * cellW\n            local cellY = startY + row * cellH\n\n            -- Highlight selected cell\n            if state.selectedModeOption == i then\n                if isMacDesktop then\n                    gfx.fillRoundRect(cellX + 4, cellY + 2, cellW - 8, cellH - 4, 5)\n                else\n                    gfx.fillRect(cellX + 4, cellY + 2, cellW - 8, cellH - 4)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n\n            -- Draw icon on left, label vertically centered to the right\n            local iconPadL = 12\n            local iconGap  = 6\n            local iconX = cellX + iconPadL\n            local iconY = cellY + math.floor((cellH - iconSize) / 2)\n            drawIcon(iconNames[i] or \"prefs\", iconX, iconY, state.selectedModeOption == i)\n\n            gfx.setFont(\"normal\")\n            local tw, th = gfx.getTextSize(mode.label)\n            local tx = iconX + iconSize + iconGap\n            local ty = cellY + math.floor((cellH - th) / 2)\n            gfx.drawText(mode.label, tx, ty)\n\n            gfx.setDrawMode(\"copy\")\n            -- Cell outline for all cells (gives button appearance)\n            if isMacDesktop then\n                gfx.drawRoundRect(cellX + 4, cellY + 2, cellW - 8, cellH - 4, 5)\n            else\n                gfx.drawRect(cellX + 4, cellY + 2, cellW - 8, cellH - 4)\n            end\n        end\n\n        -- Bottom instruction bar: show selected mode description on Mac, key hints elsewhere\n        local instructY = SCREEN_H - 30\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n        gfx.setFont(\"normal\")\n        local selectedMode = modes[state.selectedModeOption]\n        if isMacDesktop and selectedMode and selectedMode.description then\n            local _, descH = gfx.getTextSize(selectedMode.description)\n            local descY = instructY + math.floor((SCREEN_H - instructY - descH) / 2)\n            drawCenteredText(gfx, selectedMode.description, descY)\n        else\n            drawCenteredText(gfx, \"Arrows: Select  A: Choose  B: Back\", instructY + 8)\n        end\n\n        return\n    end\n\n    if state.showingMidiControls then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        local midiActions = ctx.midiActions or {}\n        gfx.setFont(\"bold\")\n        if isMacDesktop then\n            gfx.drawText(\"< Back\", 10, 10)\n        end\n        drawMenuTitle(gfx, \"MIDI CONTROLS\", 10 + menuSafeTop, isMacDesktop)\n\n        local startY = 50 + menuSafeTop\n        local instructY = SCREEN_H - 30\n        local visibleCount = math.floor((instructY - startY) / LIST_ITEM_STRIDE)\n        local total = #midiActions\n\n        -- Scroll to keep selection visible\n        local sel = state.midiControlsSelection or 1\n        local firstVisible = math.max(1, sel - visibleCount + 1)\n        firstVisible = math.min(firstVisible, math.max(1, total - visibleCount + 1))\n\n        for row = 0, visibleCount - 1 do\n            local i = firstVisible + row\n            if i > total then break end\n            local act = midiActions[i]\n            local y = startY + row * LIST_ITEM_STRIDE\n            local isSelected = state.midiControlsSelection == i\n            local isLearning = state.midiLearnMode and state.midiLearnTarget == act.id\n\n            if isSelected then\n                if isMacDesktop then\n                    gfx.fillRoundRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4, 4)\n                else\n                    gfx.fillRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n\n            gfx.drawText(act.label, 20, y)\n\n            -- Right side: current mapping or learn indicator\n            local mapping = state.midiMappings[act.id]\n            local mappingLabel\n            if isLearning then\n                local t = math.floor((getCurrentTimeMilliseconds(ctx) or 0) / 500)\n                if t % 2 == 0 then mappingLabel = \"Waiting...\" end\n            elseif mapping then\n                if mapping.type == \"cc\" then\n                    mappingLabel = \"CC \" .. tostring(mapping.number)\n                elseif mapping.type == \"note\" then\n                    mappingLabel = \"Note \" .. tostring(mapping.number)\n                end\n            end\n\n            if mappingLabel then\n                local mw = gfx.getTextSize(mappingLabel)\n                gfx.drawText(mappingLabel, SCREEN_W - mw - 20, y)\n            end\n\n            gfx.setDrawMode(\"copy\")\n            if i < total then\n                gfx.drawLine(15, y + LIST_ITEM_STRIDE - 2, SCREEN_W - 15, y + LIST_ITEM_STRIDE - 2)\n            end\n        end\n\n        drawListViewportStatus(\n            gfx,\n            firstVisible,\n            math.min(total, firstVisible + visibleCount - 1),\n            total,\n            startY,\n            instructY,\n            34\n        )\n\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n        gfx.setFont(\"normal\")\n        if state.midiLearnMode then\n            drawCenteredText(gfx, \"Press a key or pedal  B: Cancel\", instructY + 8)\n        else\n            drawCenteredText(gfx, \"Click: Learn  Right-click: Clear  B: Back\", instructY + 8)\n        end\n\n        return\n    end\n\n    if state.showingGamepadPicker then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        gfx.setFont(\"bold\")\n        if isMacDesktop then\n            gfx.drawText(\"< Back\", 10, 10)\n        end\n        drawMenuTitle(gfx, \"GAMEPAD\", 10 + menuSafeTop, isMacDesktop)\n\n        local devices = state.gamepadPickerDevices or {}\n        local startY = 50 + menuSafeTop\n        local instructY = SCREEN_H - 20\n        local visibleCount = math.floor((instructY - startY) / LIST_ITEM_STRIDE)\n        local total = #devices\n        local maxFirst = math.max(1, total - visibleCount + 1)\n        local firstVisible = math.max(1, state.gamepadPickerSelection - visibleCount + 1)\n        firstVisible = math.min(firstVisible, maxFirst)\n        local lastVisible = math.min(total, firstVisible + visibleCount - 1)\n\n        drawListViewportStatus(gfx, firstVisible, lastVisible, total, startY, instructY, 34)\n\n        for i = firstVisible, lastVisible do\n            local dev = devices[i]\n            local y = startY + (i - firstVisible) * LIST_ITEM_STRIDE\n            local isSelected = state.gamepadPickerSelection == i\n            local isActive = dev.index == 0 and not state.gamepadEnabled\n                or (state.gamepadEnabled and dev.name == state.gamepadDeviceName)\n\n            if isSelected then\n                if isMacDesktop then\n                    gfx.fillRoundRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4, 4)\n                else\n                    gfx.fillRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n\n            gfx.drawText(dev.name, 20, y)\n            if isActive then\n                local mark = \"✓\"\n                local mw = gfx.getTextSize(mark)\n                gfx.drawText(mark, SCREEN_W - mw - 20, y)\n            end\n            gfx.setDrawMode(\"copy\")\n            if i < lastVisible then\n                gfx.drawLine(15, y + LIST_ITEM_STRIDE - 2, SCREEN_W - 15, y + LIST_ITEM_STRIDE - 2)\n            end\n        end\n\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n        gfx.setFont(\"normal\")\n        drawCenteredText(gfx, \"Up/Down: Select  A: Apply  B: Back\", instructY + 10)\n\n        return\n    end\n\n\n    if state.showingMicInputPicker then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        gfx.setFont(\"bold\")\n        if isMacDesktop then\n            gfx.drawText(\"< Back\", 10, 10)\n        end\n        drawMenuTitle(gfx, \"MIC INPUT DEVICE\", 10 + menuSafeTop, isMacDesktop)\n\n        local devices = state.micInputPickerDevices or {}\n        local startY = 50 + menuSafeTop\n        local instructY = SCREEN_H - 20\n        local visibleCount = math.floor((instructY - startY) / LIST_ITEM_STRIDE)\n        local total = #devices\n        local maxFirst = math.max(1, total - visibleCount + 1)\n        local firstVisible = math.max(1, state.micInputPickerSelection - visibleCount + 1)\n        firstVisible = math.min(firstVisible, maxFirst)\n        local lastVisible = math.min(total, firstVisible + visibleCount - 1)\n\n        drawListViewportStatus(gfx, firstVisible, lastVisible, total, startY, instructY, 34)\n\n        for i = firstVisible, lastVisible do\n            local dev = devices[i]\n            local y = startY + (i - firstVisible) * LIST_ITEM_STRIDE\n            local isSelected = state.micInputPickerSelection == i\n            local isActive = dev.name == state.micInputDeviceName\n                             or (dev.index == 0 and state.micInputDeviceName == \"\")\n\n            if isSelected then\n                if isMacDesktop then\n                    gfx.fillRoundRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4, 4)\n                else\n                    gfx.fillRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n\n            gfx.drawText(dev.name, 20, y)\n            if isActive then\n                local mark = \"✓\"\n                local mw = gfx.getTextSize(mark)\n                gfx.drawText(mark, SCREEN_W - mw - 20, y)\n            end\n            gfx.setDrawMode(\"copy\")\n            if i < lastVisible then\n                gfx.drawLine(15, y + LIST_ITEM_STRIDE - 2, SCREEN_W - 15, y + LIST_ITEM_STRIDE - 2)\n            end\n        end\n\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n        gfx.setFont(\"normal\")\n        drawCenteredText(gfx, \"Up/Down: Select  A: Connect  B: Back\", instructY + 10)\n\n        return\n    end\n\n    if state.showingMidiInPicker then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        gfx.setFont(\"bold\")\n        if isMacDesktop then\n            gfx.drawText(\"< Back\", 10, 10)\n        end\n        drawMenuTitle(gfx, \"MIDI INPUT DEVICE\", 10 + menuSafeTop, isMacDesktop)\n\n        local devices = state.midiInPickerDevices or {}\n        local startY = 50 + menuSafeTop\n        local instructY = SCREEN_H - 20\n        local visibleCount = math.floor((instructY - startY) / LIST_ITEM_STRIDE)\n        local total = #devices\n        local maxFirst = math.max(1, total - visibleCount + 1)\n        local firstVisible = math.max(1, state.midiInPickerSelection - visibleCount + 1)\n        firstVisible = math.min(firstVisible, maxFirst)\n        local lastVisible = math.min(total, firstVisible + visibleCount - 1)\n\n        drawListViewportStatus(gfx, firstVisible, lastVisible, total, startY, instructY, 34)\n\n        for i = firstVisible, lastVisible do\n            local dev = devices[i]\n            local y = startY + (i - firstVisible) * LIST_ITEM_STRIDE\n            local isSelected = state.midiInPickerSelection == i\n            local isActive = dev.name == state.midiInDeviceName\n                             or (dev.index == 0 and state.midiInDeviceName == \"\")\n\n            if isSelected then\n                if isMacDesktop then\n                    gfx.fillRoundRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4, 4)\n                else\n                    gfx.fillRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n\n            gfx.drawText(dev.name, 20, y)\n            if isActive then\n                local mark = \"✓\"\n                local mw = gfx.getTextSize(mark)\n                gfx.drawText(mark, SCREEN_W - mw - 20, y)\n            end\n            gfx.setDrawMode(\"copy\")\n            if i < lastVisible then\n                gfx.drawLine(15, y + LIST_ITEM_STRIDE - 2, SCREEN_W - 15, y + LIST_ITEM_STRIDE - 2)\n            end\n        end\n\n        if total == 0 then\n            gfx.setFont(\"normal\")\n            drawCenteredText(gfx, \"No MIDI devices found\", startY + 20)\n        end\n\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n        gfx.setFont(\"normal\")\n        drawCenteredText(gfx, \"Up/Down: Select  A: Connect  B: Back\", instructY + 10)\n\n        return\n    end\n\n    if state.showingStepSelect then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        gfx.setFont(\"bold\")\n        local seqIdx = state.stepSelectSequenceIndex or 1\n        local octaveTranspose = 0\n        if state.sequenceOctaveTranspose then\n            octaveTranspose = state.sequenceOctaveTranspose[seqIdx] or 0\n        end\n        local transposeLabel = octaveTranspose > 0 and (\"+\" .. octaveTranspose) or tostring(octaveTranspose)\n        drawMenuTitle(gfx, \"STEPS - Pattern \" .. seqIdx .. \" Oct:\" .. transposeLabel, 10 + menuSafeTop, isMacDesktop)\n\n        local items = ctx.getStepMenuItems(seqIdx)\n        local instructY = SCREEN_H - 20\n        local startY = 40 + menuSafeTop\n        local visibleCount = math.max(1, math.floor((instructY - startY) / LIST_ITEM_STRIDE))\n        local totalItems = #items\n        if totalItems == 0 then\n            gfx.setFont(\"normal\")\n            drawCenteredText(gfx, \"No steps in this pattern\", 100)\n        else\n            local maxFirstVisible = math.max(1, totalItems - visibleCount + 1)\n            local firstVisible = math.max(1, state.selectedStepOption - visibleCount + 1)\n            firstVisible = math.min(firstVisible, maxFirstVisible)\n            local lastVisible = math.min(totalItems, firstVisible + visibleCount - 1)\n\n            drawListViewportStatus(gfx, firstVisible, lastVisible, totalItems, startY, instructY, 34)\n\n            for i = firstVisible, lastVisible do\n                local item = items[i]\n                local y = startY + (i - firstVisible) * LIST_ITEM_STRIDE\n                if i == state.selectedStepOption then\n                    if isMacDesktop then\n                        gfx.fillRoundRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4, 4)\n                    else\n                        gfx.fillRect(10, y - 2, listItemWidth(), LIST_ITEM_HEIGHT + 4)\n                    end\n                    gfx.setDrawMode(\"fillWhite\")\n                end\n\n                gfx.setFont(\"bold\")\n                gfx.drawText(item.label, 20, y)\n                gfx.setDrawMode(\"copy\")\n            end\n        end\n\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n        gfx.setFont(\"normal\")\n        drawCenteredText(gfx, \"Up/Down: Select  A: Choose  Left/Right: Move Step\", instructY + 8)\n        drawCenteredText(gfx, \"A+Left/Right: Seq Octave  B: Back\", instructY + 20)\n\n        return\n    end\n\n    if state.showingSequenceSelect then\n        local menuSafeTop = isMacDesktop and 0 or _safeAreaTop\n        gfx.setFont(\"bold\")\n        drawMenuTitle(gfx, \"PATTERNS\", 10 + menuSafeTop, isMacDesktop)\n\n        local items = ctx.getSequenceMenuItems()\n        local instructY = SCREEN_H - 20\n        local startY = 46 + menuSafeTop\n        local itemHeight = 32\n        local lineGap = 8\n        local itemStride = itemHeight + lineGap\n        local previewOffset = 16\n        local visibleCount = math.max(1, math.floor((instructY - startY) / itemStride))\n        local totalItems = #items\n        local maxFirstVisible = math.max(1, totalItems - visibleCount + 1)\n        local firstVisible = math.max(1, state.selectedSequenceOption - visibleCount + 1)\n        firstVisible = math.min(firstVisible, maxFirstVisible)\n        local lastVisible = math.min(totalItems, firstVisible + visibleCount - 1)\n\n        drawListViewportStatus(gfx, firstVisible, lastVisible, totalItems, startY, instructY, 34)\n\n        for i = firstVisible, lastVisible do\n            local item = items[i]\n            local y = startY + (i - firstVisible) * itemStride\n            local highlightPadding = 8\n            if i == state.selectedSequenceOption then\n                if isMacDesktop then\n                    gfx.fillRoundRect(10, y - 3, listItemWidth(), itemHeight + highlightPadding, 5)\n                else\n                    gfx.fillRect(10, y - 3, listItemWidth(), itemHeight + highlightPadding)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n\n            gfx.drawText(item.label, 20, y)\n            if item.preview then\n                gfx.setFont(\"normal\")\n                gfx.drawText(item.preview, 20, y + previewOffset)\n                gfx.setFont(\"bold\")\n            end\n            gfx.setDrawMode(\"copy\")\n        end\n\n        drawSectionDivider(gfx, instructY, isMacDesktop)\n        gfx.setFont(\"normal\")\n        drawCenteredText(gfx, \"Up/Down: Select  A: Edit Steps\", instructY + 8)\n        drawCenteredText(gfx, \"Left/Right: Reorder  B: Back\", instructY + 20)\n\n        return\n    end\n\n    if ctx.showSaveMessage then\n        gfx.drawText(\"✓ Saved!\", SCREEN_W - 60, 4 + _safeAreaTop)\n    end\n    if ctx.showImportMessage then\n        gfx.drawText(ctx.importMessageText or \"✓ Imported!\", SCREEN_W - 180, 4 + _safeAreaTop)\n    end\n\n    -- Keep header controls visible on Mac desktop even when hand visuals are shown\n    -- during playback, so transport/mic buttons remain accessible.\n    if not (state.showHandsDuringPlayback and state.isPlaying and not isMacDesktop) then\n        gfx.setFont(\"bold\")\n        local _, boldTextH = gfx.getTextSize(\"X\")\n        local filenameRowH = isMacDesktop and (_touchMode and 36 or 26) or 0\n        local topRowH = isMacDesktop and (_touchMode and 44 or 30) or 0\n        local safeTopOffset = isMacDesktop and 0 or _safeAreaTop\n        local headerTopY = filenameRowH + topRowH + safeTopOffset\n        local headerH = _touchMode and 48 or 34\n        local headerBottomY = headerTopY + headerH\n        local infoY = headerTopY + math.floor((headerH - boldTextH) / 2)\n        state._macFilenameRowH = filenameRowH\n\n        if isMacDesktop then\n            drawThemeRect(gfx, state, \"chromeTop\", 0, 0, SCREEN_W, filenameRowH)\n            drawThemeRect(gfx, state, \"chromeMid\", 0, filenameRowH, SCREEN_W, topRowH + headerH)\n            drawThemeLine(gfx, state, \"chromeLine\", 0, filenameRowH, SCREEN_W, filenameRowH)\n            drawThemeLine(gfx, state, \"chromeLine\", 0, filenameRowH + topRowH, SCREEN_W, filenameRowH + topRowH)\n            drawThemeLine(gfx, state, \"chromeLine\", 0, headerBottomY, SCREEN_W, headerBottomY)\n            setThemeColor(gfx, state, \"text\")\n        elseif safeTopOffset > 0 then\n            drawThemeRect(gfx, state, \"appBg\", 0, 0, SCREEN_W, safeTopOffset)\n        end\n\n        -- Anchor header buttons to the left edge of the screen\n        local headerButtonGap = isPortraitLayout() and 3 or 6\n        local headerLeftX = isPortraitLayout() and 4 or 8\n        for _, key in ipairs(MAC_HEADER_BUTTON_ORDER) do\n            local button = MAC_TRANSPORT_BUTTONS[key]\n            if button then\n                button.x = headerLeftX\n                headerLeftX = headerLeftX + button.width + headerButtonGap\n            end\n        end\n\n        if isMacDesktop then\n            -- Left-align top row buttons with consistent spacing\n            local topRowKeys = {}\n            local totalBtnW = 0\n            for _, key in ipairs(MAC_TOP_ROW_BUTTON_ORDER) do\n                if key ~= \"keynote\" and key ~= \"setkey\" then\n                    local button = MAC_TRANSPORT_BUTTONS[key]\n                    if button then\n                        table.insert(topRowKeys, key)\n                        totalBtnW = totalBtnW + button.width\n                    end\n                end\n            end\n            local portrait = isPortraitLayout()\n            if portrait and totalBtnW > SCREEN_W - 16 then\n                local scale = math.max(0.55, (SCREEN_W - 16) / totalBtnW)\n                totalBtnW = 0\n                for _, key in ipairs(topRowKeys) do\n                    local button = MAC_TRANSPORT_BUTTONS[key]\n                    button._portraitW = math.max(24, math.floor(button.width * scale))\n                    totalBtnW = totalBtnW + button._portraitW\n                end\n            else\n                for _, key in ipairs(topRowKeys) do\n                    MAC_TRANSPORT_BUTTONS[key]._portraitW = nil\n                end\n            end\n            local topRowBtnGap = _touchMode and 10 or 6\n            local topRowLeftX = _touchMode and 10 or 6\n            for _, key in ipairs(topRowKeys) do\n                local button = MAC_TRANSPORT_BUTTONS[key]\n                button.x = topRowLeftX\n                local bw = button._portraitW or button.width\n                topRowLeftX = topRowLeftX + bw + topRowBtnGap\n            end\n        end\n\n        local function drawMacTransportButtons()\n            state._masterVolumeSliderBounds = nil\n            if not isMacDesktop then\n                return\n            end\n\n            for _, key in ipairs(MAC_HEADER_BUTTON_ORDER) do\n                local button = MAC_TRANSPORT_BUTTONS[key]\n                local bh = button.height\n                local by = headerTopY + math.floor((headerH - bh) / 2)\n                button.y = by  -- store for hit-testing in main.lua\n                local chatLoopActive = state._cmdChatAuditionLoopText ~= nil\n                local isActive = (key == \"play\" and state.isPlaying) or\n                                 (key == \"stop\" and not state.isPlaying and not state.isPaused) or\n                                 (key == \"pause\" and state.isPaused) or\n                                 (key == \"loop\" and (state.loopPlayback or chatLoopActive)) or\n                                 (key == \"edit\" and state.editDropdownOpen) or\n                                 (key == \"mic\" and state.micStepRecording)\n                if isActive and key ~= \"view\" and key ~= \"input\" then\n                    gfx.fillRoundRect(button.x + 1, by + 1, button.width - 2, bh - 2, 3)\n                    gfx.setDrawMode(\"fillWhite\")\n                end\n\n                if key == \"volume\" then\n                    local volume = math.max(0, math.min(1, state.masterVolume or 1))\n                    local labelX = button.x + 5\n                    local _, textHeight = gfx.getTextSize(button.label)\n                    local textY = by + math.floor((bh - textHeight) / 2)\n                    gfx.drawText(button.label, labelX, textY)\n\n                    local sliderX = button.x + MASTER_VOLUME_SLIDER.trackInsetX\n                    local sliderY = by + math.floor((bh - MASTER_VOLUME_SLIDER.trackHeight) / 2)\n                    local sliderW = MASTER_VOLUME_SLIDER.trackWidth\n                    local sliderH = MASTER_VOLUME_SLIDER.trackHeight\n                    gfx.drawRoundRect(sliderX, sliderY, sliderW, sliderH, 2)\n\n                    local knobX = sliderX + math.floor(volume * (sliderW - 1))\n                    local knobY = by + math.floor((bh - MASTER_VOLUME_SLIDER.knobSize) / 2)\n                    gfx.fillCircle(knobX, knobY + math.floor(MASTER_VOLUME_SLIDER.knobSize / 2), math.floor(MASTER_VOLUME_SLIDER.knobSize / 2))\n\n                    state._masterVolumeSliderBounds = {\n                        x = sliderX,\n                        y = by,\n                        width = sliderW,\n                        height = bh\n                    }\n                else\n                    local textWidth, textHeight = gfx.getTextSize(button.label)\n                    local textX = button.x + math.floor((button.width - textWidth) / 2)\n                    local textY = by + math.floor((bh - textHeight) / 2)\n                    gfx.drawText(button.label, textX, textY)\n                end\n                gfx.setDrawMode(\"copy\")\n            end\n        end\n\n        local function drawMacTopRowButtons()\n            if not isMacDesktop then\n                return\n            end\n\n            local bh = MAC_TRANSPORT_BUTTONS.view.height\n            local by = filenameRowH + math.floor((topRowH - bh) / 2)\n            for _, key in ipairs(MAC_TOP_ROW_BUTTON_ORDER) do\n                local button = MAC_TRANSPORT_BUTTONS[key]\n                -- keynote and setkey are rendered in the transport row instead\n                if key == \"keynote\" or key == \"setkey\" then\n                    button.y = by  -- keep y updated for hit-testing even though not drawn here\n                    goto continueTopRow\n                end\n                button.y = by\n                local hasInputSelection = (state.midiInDeviceName and state.midiInDeviceName ~= \"\")\n                    or (state.micInputDeviceName and state.micInputDeviceName ~= \"\")\n                    or (state.gamepadEnabled and (state.gamepadDeviceName and state.gamepadDeviceName ~= \"\"))\n                local viewOptionsActive = state.sidebarOpen or state.showToolsRow or state.showPlaybackRow == false or state.showBarsBeatsRow or state.showNoteNames or state.useShapeNotes or state.showLyrics or state.showSolfegeLyrics or not state.hideSteps or state.showBarLines == false or state.showRomanNumerals or state.showNoteLengths == false or state.showOctaveNumbers == false\n                local pendingRootNote = state.pendingRootNote\n                if pendingRootNote == nil then\n                    pendingRootNote = state.rootNote or 0\n                end\n                if key == \"keynote\" then\n                    button.label = (ctx.solfegeNotes[pendingRootNote + 1] or \"Do\") .. \"▼\"\n                end\n                local isActive = (key == \"keynote\" and state.keynoteDropdownOpen)\n                    or (key == \"setkey\" and pendingRootNote ~= (state.rootNote or 0))\n                    or (key == \"file\" and state.fileDropdownOpen)\n                    or (key == \"view\" and (viewOptionsActive or state.displayOptionsDropdownOpen))\n                    or (key == \"input\" and (hasInputSelection\n                        or state.showingMidiInPicker\n                        or state.showingMicInputPicker\n                        or state.showingGamepadPicker\n                        or state.inputSourcesDropdownOpen))\n                local bw = button._portraitW or button.width\n                drawChromeButton(gfx, state, button.x, by, bw, bh, button.label, isActive, isMacDesktop)\n                gfx.setDrawMode(\"copy\")\n                ::continueTopRow::\n            end\n\n            drawThemeLine(gfx, state, \"chromeLine\", 0, filenameRowH + topRowH, SCREEN_W, filenameRowH + topRowH)\n        end\n\n        local displaySequence = state.sequence\n        local displaySequenceLength = state.sequenceLength\n        if state.isPlaying and ctx.getPlaybackSequence then\n            displaySequence = ctx.getPlaybackSequence()\n        end\n        if state.isPlaying and ctx.getPlaybackLength then\n            displaySequenceLength = ctx.getPlaybackLength()\n        end\n\n        local displayNote = state.selectedNote\n        local displayOctave = state.currentOctave\n        local displayStep = state.currentStep\n        if state.isPlaying and displaySequence[displayStep] then\n            -- During playback, follow the actual step being heard.\n            if displaySequence[displayStep].notes then\n                displayNote = displaySequence[displayStep].notes[1].note\n                displayOctave = displaySequence[displayStep].notes[1].octave\n            else\n                displayNote = displaySequence[displayStep].note\n                displayOctave = displaySequence[displayStep].octave\n            end\n        end\n\n        local hideNoteNames = (state.hideNoteNamesDuringPlayback and state.isPlaying)\n            or (state.singSolfegeMode and (state.hideNoteNamesDuringSing or false))\n        local useShapeNoteStaff = state.useShapeNotes\n\n        local sidebarOffset = state.sidebarOpen and SIDEBAR_WIDTH or 0\n\n        local function drawHeaderItem(text, x, y, isSelected)\n            local textWidth, textHeight = gfx.getTextSize(text)\n            if isSelected then\n                if isMacDesktop then\n                    gfx.fillRoundRect(x - 4, y - 2, textWidth + 8, textHeight + 4, 4)\n                else\n                    gfx.fillRect(x - 4, y - 2, textWidth + 8, textHeight + 4)\n                end\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            gfx.drawText(text, x, y)\n            gfx.setDrawMode(\"copy\")\n        end\n\n        -- Single header row: Compose/Sing | Select | Delete | note | length | BPM\n        -- (File name is rendered in the filename row below)\n        local fileNameX = 8\n\n        -- Mode controls live in View > Mode.\n        local _, boldH = gfx.getTextSize(\"X\")\n        local bVPad = 2   -- vertical padding\n        local lyricsOnlyActive = (state.showSolfegeTextInput == true)\n\n        state._headerStepsOnlyBtn   = nil\n        state._headerLyricsOnlyBtn  = nil\n        state._headerComposeModeBtn = nil\n        state._headerSingModeBtn    = nil\n\n        -- Transport buttons (|◀ ▶ ↻ ■) center-aligned in header row (Mac only)\n        if isMacDesktop then\n            local tbh = _touchMode and 40 or 26\n            local tby = headerTopY + math.floor((headerH - tbh) / 2)\n            local tBtnGap = _touchMode and 8 or 5\n            local begBtn  = MAC_TRANSPORT_BUTTONS.beginning\n            local playBtn = MAC_TRANSPORT_BUTTONS.play\n            local loopBtn = MAC_TRANSPORT_BUTTONS.loop\n            local stopBtn = MAC_TRANSPORT_BUTTONS.stop\n            local totalW = begBtn.width + tBtnGap + playBtn.width + tBtnGap + loopBtn.width + tBtnGap + stopBtn.width\n            local startX = math.floor((SCREEN_W - totalW) / 2)\n            begBtn.x  = startX;                                          begBtn.y  = tby\n            playBtn.x = begBtn.x + begBtn.width + tBtnGap;              playBtn.y = tby\n            loopBtn.x = playBtn.x + playBtn.width + tBtnGap;            loopBtn.y = tby\n            stopBtn.x = loopBtn.x + loopBtn.width + tBtnGap;            stopBtn.y = tby\n\n            for _, pair in ipairs({{begBtn, \"beginning\"}, {playBtn, \"play\"}, {loopBtn, \"loop\"}, {stopBtn, \"stop\"}}) do\n                local button, key = pair[1], pair[2]\n                local chatLoopActive = state._cmdChatAuditionLoopText ~= nil\n                local isActive = (key == \"play\" and (state.isPlaying or state.isPaused)) or\n                                 (key == \"stop\" and not state.isPlaying and not state.isPaused) or\n                                 (key == \"loop\" and (state.loopPlayback or chatLoopActive))\n                local lbl = button.label\n                if key == \"play\" then\n                    lbl = state.isPlaying and \"▮▮\" or \"▶\"\n                end\n                drawChromeButton(gfx, state, button.x, tby, button.width, tbh, lbl, isActive, isMacDesktop)\n                gfx.setDrawMode(\"copy\")\n            end\n        end\n\n        -- Sample indicator in header (Edit mode only)\n        if not state.singSolfegeMode then\n            if ctx.useSampleMode and ctx.recordedSample then\n                gfx.setFont(\"normal\")\n                local sW = gfx.getTextSize(\"[S]\")\n                gfx.drawText(\"[S]\", SCREEN_W - sW - 2, infoY)\n                gfx.setFont(\"bold\")\n            end\n\n            if not isMacDesktop then\n                gfx.setFont(\"normal\")\n                local modeLabel = state.editMode == \"chord\" and \"[C]\" or state.editMode == \"delete\" and \"[D]\" or \"[N]\"\n                local mW = gfx.getTextSize(modeLabel)\n                gfx.drawText(modeLabel, SCREEN_W - mW - 2, infoY)\n                gfx.setFont(\"bold\")\n            end\n        end\n\n        drawMacTopRowButtons()\n        drawMacTransportButtons()\n\n        gfx.drawLine(0, headerBottomY, SCREEN_W, headerBottomY)\n\n        -- Home button\n        if isMacDesktop and filenameRowH > 0 then\n            local homeBtn = MAC_TRANSPORT_BUTTONS.home\n            local homeBh = homeBtn.height - 4\n            local homeBy = math.floor((filenameRowH - homeBh) / 2)\n            homeBtn.x = 4\n            homeBtn.y = homeBy\n            drawChromeButton(gfx, state, homeBtn.x, homeBy, homeBtn.width, homeBh, homeBtn.label, false, isMacDesktop)\n            gfx.setDrawMode(\"copy\")\n        elseif not isMacDesktop then\n            local homeBtn = MAC_TRANSPORT_BUTTONS.home\n            local homeBh = 20\n            local homeBy = headerTopY + math.floor((headerH - homeBh) / 2)\n            homeBtn.x = 4\n            homeBtn.y = homeBy\n            drawChromeButton(gfx, state, homeBtn.x, homeBy, homeBtn.width, homeBh, homeBtn.label, false, false)\n            gfx.setDrawMode(\"copy\")\n        end\n\n        -- Filename row (Mac only): project name at the very top\n        if isMacDesktop and filenameRowH > 0 then\n            if ctx.musicXMLFileName then\n                local iconW, iconH = 13, 10\n                local iconGap = 5\n                local iconCenterY = math.floor(filenameRowH / 2)\n                local iconY = iconCenterY - math.floor(iconH / 2)\n\n                gfx.setFont(\"normal\")\n                local musicXMLText = tostring(ctx.musicXMLFileName)\n                musicXMLText = musicXMLText:gsub(\"%.[Mm][Uu][Ss][Ii][Cc][Xx][Mm][Ll]$\", \"\"):gsub(\"%.[Xx][Mm][Ll]$\", \"\"):gsub(\"%.[Ss][Oo][Ll][Ff][Ee][Gg][Ee]$\", \"\")\n                local maxChars = 34\n                if #musicXMLText > maxChars then\n                    musicXMLText = \"...\" .. musicXMLText:sub(#musicXMLText - maxChars + 4)\n                end\n                local fileNameW, fileNameH = gfx.getTextSize(musicXMLText)\n                local textStartX = math.floor((SCREEN_W - fileNameW) / 2)\n                if state.musicXMLFilenameEditing then\n                    local buf = tostring(state.musicXMLFilenameInputBuffer or \"\")\n                    local cur = state.musicXMLFilenameInputCursor or #buf\n                    local bufW = select(1, gfx.getTextSize(buf))\n                    local editStartX = math.floor((SCREEN_W - bufW) / 2)\n                    local charXPositions = {}\n                    for i = 0, #buf do\n                        local prefixW = (i == 0) and 0 or select(1, gfx.getTextSize(buf:sub(1, i)))\n                        charXPositions[i + 1] = editStartX + prefixW\n                    end\n                    state._filenameCharXPositions = charXPositions\n                    local blinkOn = (math.floor((ctx.getCurrentTimeMilliseconds and ctx.getCurrentTimeMilliseconds() or 0) / 500) % 2 == 0)\n                    local cursorChar = blinkOn and \"|\" or \" \"\n                    musicXMLText = buf:sub(1, cur) .. cursorChar .. buf:sub(cur + 1)\n                    fileNameW, fileNameH = gfx.getTextSize(musicXMLText)\n                    textStartX = math.floor((SCREEN_W - fileNameW) / 2)\n                else\n                    state._filenameCharXPositions = nil\n                end\n\n                -- Draw folder icon just left of the centered text\n                local iconX = textStartX - iconGap - iconW\n                local isPopupOpen = state.pathPopupOpen\n                if isPopupOpen then\n                    gfx.fillRoundRect(iconX - 1, iconY - 1, iconW + 2, iconH + 2, 2)\n                    gfx.setDrawMode(\"fillWhite\")\n                end\n                gfx.fillRect(iconX, iconY, 5, 3)\n                gfx.fillRect(iconX, iconY + 2, iconW, iconH - 2)\n                if isPopupOpen then\n                    gfx.setDrawMode(\"copy\")\n                end\n                state._folderIconX = iconX - 1\n                state._folderIconY = iconY - 1\n                state._folderIconW = iconW + 2\n                state._folderIconH = iconH + 2\n\n                local fileNameY = math.floor((filenameRowH - fileNameH) / 2)\n                state._musicXMLHeaderX = textStartX\n                state._musicXMLHeaderY = 0\n                state._musicXMLHeaderW = fileNameW\n                state._musicXMLHeaderH = filenameRowH\n                gfx.drawText(musicXMLText, textStartX, fileNameY)\n                gfx.setFont(\"bold\")\n            else\n                state._musicXMLHeaderX = nil\n                state._musicXMLHeaderY = nil\n                state._musicXMLHeaderW = nil\n                state._musicXMLHeaderH = nil\n                state._folderIconX = nil\n                state._folderIconY = nil\n                state._folderIconW = nil\n                state._folderIconH = nil\n            end\n            gfx.drawLine(0, filenameRowH, SCREEN_W, filenameRowH)\n        end\n\n        -- Sub-header rows: Select/Undo/Redo/Delete row + step count/key/BPM row (Edit mode only)\n        if not state.singSolfegeMode then\n            local row2H = (state.showToolsRow and not lyricsOnlyActive) and 28 or 0\n            local _, boldH2 = gfx.getTextSize(\"X\")\n            local row2TopY = headerBottomY\n            local row2Y = row2TopY + math.floor((row2H - boldH2) / 2)\n\n            if state.showToolsRow and not lyricsOnlyActive then\n                -- Row 2: icons evenly distributed across full width\n                local row2Columns = 16\n                local colW = math.floor(SCREEN_W / row2Columns)\n                local pad = _touchMode and 8 or 2\n                local vpad = _touchMode and 8 or 2\n                local function drawRow2Btn(label, col, isActive)\n                    local lw = gfx.getTextSize(label)\n                    local bx = math.floor(colW * col + (colW - lw) / 2)\n                    local bw = lw + pad * 2\n                    local bh = boldH2 + vpad * 2\n                    local ox = bx - pad\n                    local oy = row2Y - vpad\n                    drawChromeButton(gfx, state, ox, oy, bw, bh, label, isActive, isMacDesktop)\n                    return bx, lw\n                end\n\n                local selectLabel = \"Sel\"  -- multi-select mode\n                local selectX, selectW = drawRow2Btn(selectLabel, 0, state.headerSelection == 2 or state.multiSelectMode)\n                state._multiSelectHeaderX = selectX\n                state._multiSelectHeaderW = selectW\n\n                local copyLabel = \"Cpy\"  -- copy selected steps\n                local copyActive = state.multiSelectMode and state.selectedSteps and next(state.selectedSteps) ~= nil\n                local copyX, copyW = drawRow2Btn(copyLabel, 1, copyActive)\n                state._copyHeaderX = copyX\n                state._copyHeaderW = copyW\n\n                state._pasteHeaderX = nil\n                state._pasteHeaderW = nil\n\n                local restLabel = \"Rst\"  -- convert to rest\n                local hasSelection = state.multiSelectMode and state.selectedSteps and next(state.selectedSteps) ~= nil\n                local restX, restW = drawRow2Btn(restLabel, 2, hasSelection)\n                state._restHeaderX = restX\n                state._restHeaderW = restW\n\n                local tieLabel = \"Tie\"  -- tie/glue selected steps\n                local tieX, tieW = drawRow2Btn(tieLabel, 3, hasSelection)\n                state._tieHeaderX = tieX\n                state._tieHeaderW = tieW\n\n                local deleteLabel = \"Del\"  -- delete mode\n                local deleteX, deleteW = drawRow2Btn(deleteLabel, 4, state.editMode == \"delete\")\n                state._deleteHeaderX = deleteX\n                state._deleteHeaderW = deleteW\n\n                local muteLabel = \"Mut\"  -- mute mode\n                local muteX, muteW = drawRow2Btn(muteLabel, 5, state.muteMode == true)\n                state._muteHeaderX = muteX\n                state._muteHeaderW = muteW\n\n                local gateLabel = \"Gat\"  -- gate mode\n                local gateX, gateW = drawRow2Btn(gateLabel, 6, state.gateMode == true)\n                state._gateHeaderX = gateX\n                state._gateHeaderW = gateW\n\n                local octDownX2, octDownW2 = drawRow2Btn(\"O−\", 7, false)\n                state._octDownX = octDownX2\n                state._octDownW = octDownW2\n                local octUpX2, octUpW2 = drawRow2Btn(\"O+\", 8, false)\n                state._octUpX = octUpX2\n                state._octUpW = octUpW2\n                local _rb = state.rowBreakAfterStep\n                local rowBreakActive = (type(_rb) == \"table\" and #_rb > 0) or (type(_rb) == \"number\" and _rb >= 1)\n                local rowBreakX, rowBreakW = drawRow2Btn(\"↵\", 9, rowBreakActive)\n                state._rowBreakHeaderX = rowBreakX\n                state._rowBreakHeaderW = rowBreakW\n\n                local paragraphBreakActive = false\n                do\n                    local step = state.sequence and state.sequence[state.currentStep or 1]\n                    local lyric = step and step.lyric\n                    paragraphBreakActive = type(lyric) == \"string\" and lyric:find(\"\\n\\n\", 1, true) ~= nil\n                end\n                local paragraphBreakX, paragraphBreakW = drawRow2Btn(\"¶\", 10, paragraphBreakActive)\n                state._paragraphBreakHeaderX = paragraphBreakX\n                state._paragraphBreakHeaderW = paragraphBreakW\n\n                local loopStart = state.stepLoopStart\n                local loopEnd   = state.stepLoopEnd\n                local loopStartX, loopStartW = drawRow2Btn(\"[\", 12, loopStart ~= nil)\n                state._stepLoopStartHeaderX = loopStartX\n                state._stepLoopStartHeaderW = loopStartW\n                local loopEndX, loopEndW = drawRow2Btn(\"]\", 13, loopEnd ~= nil)\n                state._stepLoopEndHeaderX = loopEndX\n                state._stepLoopEndHeaderW = loopEndW\n\n                local undoX, undoW = drawRow2Btn(\"Und\", 14, false)\n                state._undoHeaderX = undoX\n                state._undoHeaderW = undoW\n                local redoX, redoW = drawRow2Btn(\"Red\", 15, false)\n                state._redoHeaderX = redoX\n                state._redoHeaderW = redoW\n\n                gfx.drawLine(0, row2TopY + row2H, SCREEN_W, row2TopY + row2H)\n            else\n                state._multiSelectHeaderX = nil\n                state._multiSelectHeaderW = nil\n                state._copyHeaderX = nil\n                state._copyHeaderW = nil\n                state._pasteHeaderX = nil\n                state._pasteHeaderW = nil\n                state._restHeaderX = nil\n                state._restHeaderW = nil\n                state._tieHeaderX = nil\n                state._tieHeaderW = nil\n                state._deleteHeaderX = nil\n                state._deleteHeaderW = nil\n                state._muteHeaderX = nil\n                state._muteHeaderW = nil\n                state._gateHeaderX = nil\n                state._gateHeaderW = nil\n                state._octDownX = nil\n                state._octDownW = nil\n                state._octUpX = nil\n                state._octUpW = nil\n                state._rowBreakHeaderX = nil\n                state._rowBreakHeaderW = nil\n                state._paragraphBreakHeaderX = nil\n                state._paragraphBreakHeaderW = nil\n                state._stepLoopStartHeaderX = nil\n                state._stepLoopStartHeaderW = nil\n                state._stepLoopEndHeaderX = nil\n                state._stepLoopEndHeaderW = nil\n                state._undoHeaderX = nil\n                state._undoHeaderW = nil\n                state._redoHeaderX = nil\n                state._redoHeaderW = nil\n            end\n\n            local subHeaderH = (state.showBarsBeatsRow and not lyricsOnlyActive) and 24 or 0\n            gfx.setFont(\"normal\")\n            local _, normH2 = gfx.getTextSize(\"X\")\n            local subInfoY = row2TopY + row2H + math.floor((math.max(subHeaderH, normH2) - normH2) / 2)\n            local subSidebarOffset = state.sidebarOpen and SIDEBAR_WIDTH or 0\n\n            if state.showBarsBeatsRow and not lyricsOnlyActive then\n            -- Sequence length / live position counter (Logic Pro style)\n            -- Playing/paused → Bar.Beat counter; stopped → Bars:N length\n            local stepsPerBar = core.getStepsPerBar(state.meterNumerator, state.meterDenominator, state.stepBeats)\n            local cursor = (math.floor(os.clock() * 2) % 2 == 0) and \"|\" or \" \"\n            local seqLenDisp\n            local isPositionMode = (state.isPlaying or state.isPaused) and stepsPerBar ~= nil\n            if state.seekEditing then\n                local buf = state.seekInputBuffer or \"\"\n                local prefix = stepsPerBar and \"Bar>\" or \"Step>\"\n                seqLenDisp = prefix .. (buf ~= \"\" and buf or \"\") .. cursor\n            elseif isPositionMode then\n                -- Live Bar.Beat counter (Logic Pro style)\n                local posStep = state.currentPlaybackStep or 1\n                seqLenDisp = core.getBarBeatDisplay(posStep, state.meterNumerator, state.meterDenominator, state.stepBeats)\n            elseif state.barEditing then\n                local buf = state.barInputBuffer or \"\"\n                local prefix = stepsPerBar and \"Bars:\" or \"Steps:\"\n                seqLenDisp = prefix .. (buf ~= \"\" and buf or \"\") .. cursor\n            elseif stepsPerBar then\n                local numBars = (displaySequenceLength > 0) and math.ceil(displaySequenceLength / stepsPerBar) or 0\n                seqLenDisp = \"Bars:\" .. tostring(numBars)\n            else\n                seqLenDisp = \"Steps:\" .. tostring(displaySequenceLength)\n            end\n            local octT = state.sequenceOctaveTranspose[state.activeSequenceIndex] or 0\n            if octT ~= 0 and not state.barEditing and not state.seekEditing and not isPositionMode then\n                seqLenDisp = seqLenDisp .. \" Oct:\" .. (octT > 0 and \"+\" or \"\") .. octT\n            end\n            local seqLenX = 8 + subSidebarOffset\n            local seqLenW = gfx.getTextSize(seqLenDisp)\n            drawHeaderItem(seqLenDisp, seqLenX, subInfoY, state.seekEditing or state.barEditing or state.headerSelection == 4)\n            state._seqLenHeaderX = seqLenX\n            state._seqLenHeaderW = seqLenW\n            state._stepsPerBar   = stepsPerBar\n\n            -- Beats display (only when stepBeats < 1, i.e. multiple steps per beat)\n            local stepsPerBeat = core.getStepsPerBeat(state.stepBeats)\n            local beatsEndX = seqLenX + seqLenW\n            if stepsPerBeat then\n                local numBeats = displaySequenceLength > 0 and math.ceil(displaySequenceLength / stepsPerBeat) or 0\n                local beatsDisp\n                if state.beatEditing then\n                    local buf = state.beatInputBuffer or \"\"\n                    beatsDisp = \"Beats:\" .. (buf ~= \"\" and buf or \"\") .. cursor\n                else\n                    beatsDisp = \"Beats:\" .. tostring(numBeats)\n                end\n                local bx = beatsEndX + 14\n                local bw = gfx.getTextSize(beatsDisp)\n                drawHeaderItem(beatsDisp, bx, subInfoY, state.beatEditing or state.headerSelection == 10)\n                state._beatsHeaderX = bx\n                state._beatsHeaderW = bw\n                state._stepsPerBeat = stepsPerBeat\n                beatsEndX = bx + bw\n            else\n                state._beatsHeaderX = nil\n                state._beatsHeaderW = nil\n                state._stepsPerBeat = nil\n            end\n\n            -- Step duration display (always visible)\n            local stepDurDisp = \"Step:\" .. core.getStepBeatsShortLabel(state.stepBeats)\n            local sdx = beatsEndX + 14\n            local sdw = gfx.getTextSize(stepDurDisp)\n            drawHeaderItem(stepDurDisp, sdx, subInfoY, state.headerSelection == 11)\n            state._stepDurHeaderX = sdx\n            state._stepDurHeaderW = sdw\n\n            -- Step cursor / playback position\n            local posDisp = \"At:\" .. tostring(state.currentStep or 1)\n            local posW = gfx.getTextSize(posDisp)\n            local posX = sdx + sdw + 14\n            gfx.setFont(\"normal\")\n            drawHeaderItem(posDisp, posX, subInfoY, false)\n            gfx.setFont(\"bold\")\n            local posEndX = posX + posW\n\n            -- Key selector (moved to the left side of the compose row)\n            local keyDisp = \"Key:\" .. ctx.solfegeNotes[(state.rootNote or 0) + 1]\n            local keyW = gfx.getTextSize(keyDisp)\n            local keyX = posEndX + 14\n            drawHeaderItem(keyDisp, keyX, subInfoY, state.headerSelection == 6 or state.keyFocused)\n\n            -- Syllable picker / Note display (to the right of key selector)\n            local pickerStartX = keyX + keyW + 14\n            if hideNoteNames then\n                drawHeaderItem(\"Listen\", pickerStartX, subInfoY, state.headerSelection == 3)\n                state._syllablePickerBtns = nil\n            elseif state.editMode == \"chord\" then\n                local currentChordStep = state.sequence[state.currentStep]\n                local noteDisplay = \"\"\n                if currentChordStep and core.isChord(currentChordStep) then\n                    local noteNames = {}\n                    for i, noteData in ipairs(currentChordStep.notes) do\n                        local noteName = ctx.solfegeNotes[noteData.note + 1] .. noteData.octave\n                        if i == state.chordEditIndex then\n                            noteName = \"[\" .. noteName .. \"]\"\n                        end\n                        table.insert(noteNames, noteName)\n                    end\n                    noteDisplay = table.concat(noteNames, \" \") .. \" (\" .. #currentChordStep.notes .. \"/3)\"\n                else\n                    noteDisplay = \"Chord: Empty\"\n                end\n                drawHeaderItem(noteDisplay, pickerStartX, subInfoY, state.headerSelection == 3)\n                state._syllablePickerBtns = nil\n            elseif state.editMode == \"delete\" then\n                drawHeaderItem(\"Delete Mode\", pickerStartX, subInfoY, state.headerSelection == 3)\n                state._syllablePickerBtns = nil\n            else\n                -- Show current note name; append melisma marker when active on the selected step.\n                local noteDisplay = ctx.solfegeNotes[displayNote + 1] .. displayOctave\n                local currentStep = state.sequence and state.sequence[state.currentStep]\n                if currentStep and not core.isChord(currentStep) and currentStep.lyric == \"_\" then\n                    noteDisplay = noteDisplay .. \" _\"\n                end\n                drawHeaderItem(noteDisplay, pickerStartX, subInfoY, state.headerSelection == 3)\n                state._syllablePickerBtns = nil\n                state._notePickerTrigger = nil\n            end\n\n            -- BPM (right)\n            local bpmBuf = state.bpmInputBuffer or \"\"\n            local bpmBlinkOn = (math.floor(os.clock() * 2) % 2 == 0)\n            local bpmCursorStr = bpmBlinkOn and \"|\" or \" \"\n            local bpmDisp\n            if state.bpmEditing then\n                local displayBuf = #bpmBuf > 0 and bpmBuf or tostring(state.tempo)\n                local bpmCur = (state.bpmInputCursor ~= nil and #bpmBuf > 0) and state.bpmInputCursor or #displayBuf\n                bpmDisp = \"BPM:\" .. displayBuf:sub(1, bpmCur) .. bpmCursorStr .. displayBuf:sub(bpmCur + 1)\n            else\n                bpmDisp = \"BPM:\" .. state.tempo\n            end\n            local bpmW = gfx.getTextSize(bpmDisp)\n            local bpmX = SCREEN_W - bpmW - 8\n            -- Store char X positions for click-to-cursor (only when buffer has content)\n            if state.bpmEditing and #bpmBuf > 0 then\n                local prefixW = select(1, gfx.getTextSize(\"BPM:\"))\n                local charXPos = {}\n                for i = 0, #bpmBuf do\n                    local numW = i == 0 and 0 or select(1, gfx.getTextSize(bpmBuf:sub(1, i)))\n                    charXPos[i + 1] = bpmX + prefixW + numW\n                end\n                state._bpmCharXPositions = charXPos\n            else\n                state._bpmCharXPositions = nil\n            end\n\n            -- Meter (left of BPM)\n            local meterDisp = \"Time:\" .. core.getTimeSignatureLabel(state.meterNumerator, state.meterDenominator)\n            local meterW = gfx.getTextSize(meterDisp)\n            local meterX = bpmX - meterW - 6\n\n            -- Loop status (left of Meter)\n            local loopDisp = \"Loop:\" .. (state.loopPlayback and \"On\" or \"Off\")\n            local loopW = gfx.getTextSize(loopDisp)\n            local loopX = meterX - loopW - 6\n\n            -- Store positions for click detection in main.lua\n            state._keyHeaderX = keyX\n            state._keyHeaderW = keyW\n            state._loopHeaderX = loopX\n            state._loopHeaderW = loopW\n            state._meterHeaderX = meterX\n            state._meterHeaderW = meterW\n            state._bpmHeaderX = bpmX\n            state._bpmHeaderW = bpmW\n\n            drawHeaderItem(loopDisp, loopX, subInfoY, state.loopPlayback)\n            drawHeaderItem(meterDisp, meterX, subInfoY, state.headerSelection == 9)\n            drawHeaderItem(bpmDisp, bpmX, subInfoY, state.bpmEditing or state.headerSelection == 5 or state.bpmFocused)\n\n            gfx.setFont(\"bold\")\n            gfx.drawLine(0, row2TopY + row2H + subHeaderH, SCREEN_W, row2TopY + row2H + subHeaderH)\n            else\n                state._seqLenHeaderX = nil\n                state._seqLenHeaderW = nil\n                state._stepsPerBar = nil\n                state._beatsHeaderX = nil\n                state._beatsHeaderW = nil\n                state._stepsPerBeat = nil\n                state._stepDurHeaderX = nil\n                state._stepDurHeaderW = nil\n                state._keyHeaderX = nil\n                state._keyHeaderW = nil\n                state._loopHeaderX = nil\n                state._loopHeaderW = nil\n                state._meterHeaderX = nil\n                state._meterHeaderW = nil\n                state._bpmHeaderX = nil\n                state._bpmHeaderW = nil\n            end\n        end\n\n        -- Transport row: play, pause, stop (desktop playback row, below sub-header)\n        if isMacDesktop then\n            local _tRow2H = (state.showToolsRow and not lyricsOnlyActive) and (_touchMode and 44 or 28) or 0\n            local _tSubH  = (not state.singSolfegeMode and state.showBarsBeatsRow and not lyricsOnlyActive) and (_touchMode and 40 or 24) or 0\n            local showPlaybackRow = (state.showPlaybackRow ~= false) and not lyricsOnlyActive\n            local transportRowY = topRowH + headerH + filenameRowH + _tRow2H + _tSubH\n            local _transportRowBaseH = _touchMode and 48 or 26\n            local transportRowH = showPlaybackRow and _transportRowBaseH or 0\n            local bh = _touchMode and 40 or 20\n            local by = transportRowY + math.floor((_transportRowBaseH - bh) / 2)\n\n            if showPlaybackRow then\n                -- Left block: Mute | Keynote | Set (left-aligned in transport row)\n                local btnGap = isPortraitLayout() and 2 or 4\n                local leftX = isPortraitLayout() and 4 or 8\n                local muBtn2 = MAC_TRANSPORT_BUTTONS.mute\n                muBtn2.x = leftX; muBtn2.y = by\n                leftX = leftX + muBtn2.width + btnGap\n                local pendingRootNoteT = state.pendingRootNote\n                if pendingRootNoteT == nil then pendingRootNoteT = state.rootNote or 0 end\n                local keynoteBtn = MAC_TRANSPORT_BUTTONS.keynote\n                keynoteBtn.label = (ctx.solfegeNotes[pendingRootNoteT + 1] or \"Do\") .. \"▼\"\n                keynoteBtn.x = leftX; keynoteBtn.y = by\n                leftX = leftX + keynoteBtn.width + btnGap\n                local setkeyBtn = MAC_TRANSPORT_BUTTONS.setkey\n                setkeyBtn.x = leftX; setkeyBtn.y = by\n                leftX = leftX + setkeyBtn.width + btnGap\n\n                -- Center rec/loop/volume in remaining space (beginning/play/stop live in header row on Mac)\n                local transportKeys = isMacDesktop\n                    and {\"rec\", \"volume\"}\n                    or  {\"beginning\", \"play\", \"stop\", \"rec\", \"loop\", \"volume\"}\n                local totalBtnW = 0\n                for i, k in ipairs(transportKeys) do\n                    totalBtnW = totalBtnW + MAC_TRANSPORT_BUTTONS[k].width + (i > 1 and btnGap or 0)\n                end\n                local firstKey = transportKeys[1]\n                MAC_TRANSPORT_BUTTONS[firstKey].x = math.floor((leftX + SCREEN_W - totalBtnW) / 2)\n                for i = 2, #transportKeys do\n                    local prev = MAC_TRANSPORT_BUTTONS[transportKeys[i - 1]]\n                    MAC_TRANSPORT_BUTTONS[transportKeys[i]].x = prev.x + prev.width + btnGap\n                end\n\n                for _, key in ipairs(transportKeys) do\n                    local button = MAC_TRANSPORT_BUTTONS[key]\n                    button.y = by  -- store for hit-testing in main.lua\n                    local chatLoopActive = state._cmdChatAuditionLoopText ~= nil\n                    local isActive = (key == \"play\" and (state.isPlaying or state.isPaused)) or\n                                     (key == \"stop\" and not state.isPlaying and not state.isPaused) or\n                                     (key == \"loop\" and (state.loopPlayback or chatLoopActive)) or\n                                     (key == \"rec\" and state.midiLiveRecord)\n                    local showOutline = (key == \"beginning\") or\n                                        (key == \"play\" and not state.isPlaying and not state.isPaused) or\n                                        (key == \"stop\" and (state.isPlaying or state.isPaused)) or\n                                        (key == \"rec\") or\n                                        (key == \"volume\") or\n                                        (key == \"loop\")\n                    if showOutline then\n                        gfx.drawRoundRect(button.x, by, button.width, bh, 4)\n                    end\n                    if isActive then\n                        gfx.fillRoundRect(button.x + 1, by + 1, button.width - 2, bh - 2, 3)\n                        gfx.setDrawMode(\"fillWhite\")\n                    end\n                    if key == \"volume\" then\n                        local volume = math.max(0, math.min(1, state.masterVolume or 1))\n                        local labelX = button.x + 5\n                        local _, textHeight = gfx.getTextSize(button.label)\n                        local textY = by + math.floor((bh - textHeight) / 2)\n                        gfx.drawText(button.label, labelX, textY)\n\n                        local sliderX = button.x + MASTER_VOLUME_SLIDER.trackInsetX\n                        local sliderY = by + math.floor((bh - MASTER_VOLUME_SLIDER.trackHeight) / 2)\n                        local sliderW = MASTER_VOLUME_SLIDER.trackWidth\n                        local sliderH = MASTER_VOLUME_SLIDER.trackHeight\n                        gfx.drawRoundRect(sliderX, sliderY, sliderW, sliderH, 2)\n\n                        local knobX = sliderX + math.floor(volume * (sliderW - 1))\n                        local knobY = by + math.floor((bh - MASTER_VOLUME_SLIDER.knobSize) / 2)\n                        gfx.fillCircle(knobX, knobY + math.floor(MASTER_VOLUME_SLIDER.knobSize / 2), math.floor(MASTER_VOLUME_SLIDER.knobSize / 2))\n\n                        state._masterVolumeSliderBounds = {\n                            x = sliderX,\n                            y = by,\n                            width = sliderW,\n                            height = bh\n                        }\n                    else\n                        local lbl = button.label\n                        if key == \"play\" then\n                            lbl = state.isPlaying and \"▮▮\" or \"▶\"\n                        end\n                        local textWidth, textHeight = gfx.getTextSize(lbl)\n                        local textX = button.x + math.floor((button.width - textWidth) / 2)\n                        local textY = by + math.floor((bh - textHeight) / 2)\n                        gfx.drawText(lbl, textX, textY)\n                    end\n                    gfx.setDrawMode(\"copy\")\n                end\n\n                -- Left block: Mute | Keynote | Set\n                do\n                    local muBtn = MAC_TRANSPORT_BUTTONS.mute\n                    local muteActive = state.muteStepPreview\n                    gfx.drawRoundRect(muBtn.x, by, muBtn.width, bh, 4)\n                    if muteActive then\n                        gfx.fillRoundRect(muBtn.x + 1, by + 1, muBtn.width - 2, bh - 2, 3)\n                        gfx.setDrawMode(\"fillWhite\")\n                    end\n                    local mutw, muth = gfx.getTextSize(muBtn.label)\n                    gfx.drawText(muBtn.label, muBtn.x + math.floor((muBtn.width - mutw) / 2), by + math.floor((bh - muth) / 2))\n                    gfx.setDrawMode(\"copy\")\n                end\n                do\n                    local kb = MAC_TRANSPORT_BUTTONS.keynote\n                    local kActive = state.keynoteDropdownOpen\n                    if kActive then\n                        gfx.fillRoundRect(kb.x + 1, by + 1, kb.width - 2, bh - 2, 3)\n                        gfx.setDrawMode(\"fillWhite\")\n                    end\n                    gfx.drawRoundRect(kb.x, by, kb.width, bh, 4)\n                    local kw, kh = gfx.getTextSize(kb.label)\n                    gfx.drawText(kb.label, kb.x + math.floor((kb.width - kw) / 2), by + math.floor((bh - kh) / 2))\n                    gfx.setDrawMode(\"copy\")\n                end\n                do\n                    local sb = MAC_TRANSPORT_BUTTONS.setkey\n                    local pendRN = state.pendingRootNote\n                    if pendRN == nil then pendRN = state.rootNote or 0 end\n                    local sActive = pendRN ~= (state.rootNote or 0)\n                    if sActive then\n                        gfx.fillRoundRect(sb.x + 1, by + 1, sb.width - 2, bh - 2, 3)\n                        gfx.setDrawMode(\"fillWhite\")\n                    end\n                    gfx.drawRoundRect(sb.x, by, sb.width, bh, 4)\n                    local sw, sh = gfx.getTextSize(sb.label)\n                    gfx.drawText(sb.label, sb.x + math.floor((sb.width - sw) / 2), by + math.floor((bh - sh) / 2))\n                    gfx.setDrawMode(\"copy\")\n                end\n            else\n                state._masterVolumeSliderBounds = nil\n            end\n\n            -- New row below transport: Mic button and inline meter\n            if state.showMicRow ~= false and not lyricsOnlyActive then\n                local newRowY = transportRowY + transportRowH\n                local newRowH = _touchMode and 48 or 26\n                local newBh = _touchMode and 40 or 20\n                local newBy = newRowY + math.floor((newRowH - newBh) / 2)\n\n                -- Mic button: left aligned in the row below transport\n                local micBtn = MAC_TRANSPORT_BUTTONS.mic\n                micBtn.x = 8\n                micBtn.y = newBy\n                gfx.drawRoundRect(micBtn.x, newBy, micBtn.width, newBh, 4)\n                if state.micStepRecording then\n                    gfx.fillRoundRect(micBtn.x + 1, newBy + 1, micBtn.width - 2, newBh - 2, 3)\n                    gfx.setDrawMode(\"fillWhite\")\n                end\n                local mtw, mth = gfx.getTextSize(micBtn.label)\n                gfx.drawText(micBtn.label, micBtn.x + math.floor((micBtn.width - mtw) / 2), newBy + math.floor((newBh - mth) / 2) - 1)\n                gfx.setDrawMode(\"copy\")\n\n                -- Inline meter to the right of Mic button\n                local meterX = micBtn.x + micBtn.width + 6\n                local meterW = SCREEN_W - meterX - 4\n                local meterY = newBy + math.floor((newBh - 10) / 2)\n                gfx.setFont(\"normal\")\n                if state.singSolfegeMode then\n                    drawPitchMeter(gfx, meterY, state, ctx.solfegeNotes, isMacDesktop, meterX, meterW)\n                elseif state.micStepRecording then\n                    drawMicLevelIndicator(gfx, meterY, state, isMacDesktop, meterX, meterW)\n                end\n                gfx.setFont(\"bold\")\n\n                gfx.drawLine(0, newRowY + newRowH, SCREEN_W, newRowY + newRowH)\n                state._gridStartY = newRowY + newRowH + 12\n            else\n                state._gridStartY = transportRowY + transportRowH + 12\n            end\n        end\n    end\n\n    if state.showHandsDuringPlayback and state.isPlaying then\n        local playbackSequence = state.sequence\n        if ctx.getPlaybackSequence then\n            playbackSequence = ctx.getPlaybackSequence()\n        end\n        local stepData = playbackSequence[state.currentPlaybackStep]\n        if stepData then\n            local handImage = ctx.getHandImage(stepData.note)\n            if handImage then\n                local handWidth, handHeight = gfx.getImageSize(handImage)\n                local maxWidth = SCREEN_W\n                local maxHeight = SCREEN_H\n                local scale = math.max(maxWidth / handWidth, maxHeight / handHeight)\n                local drawWidth = handWidth * scale\n                local drawHeight = handHeight * scale\n                local handX = (SCREEN_W - drawWidth) / 2\n                local handY = (SCREEN_H - drawHeight) / 2\n                gfx.drawImageScaled(handImage, handX, handY, scale, scale)\n            end\n        end\n    end\n\n    -- Draw pitch meter below divider in Sing mode (non-Mac only; Mac draws it inline in the mic/keynote row)\n    local pitchMeterHeight = 0\n    local pitchMeterY = 82  -- after transport row + mic/keynote row (y=28+26+26=80) in Sing mode\n    if state.singSolfegeMode and not isMacDesktop and not (state.showHandsDuringPlayback and state.isPlaying) then\n        gfx.setFont(\"normal\")\n        drawPitchMeter(gfx, pitchMeterY, state, ctx.solfegeNotes, isMacDesktop)\n        gfx.setFont(\"bold\")\n        pitchMeterHeight = 12  -- meter + gap\n    end\n\n    local effectiveHideSteps = state.hideSteps or lyricsOnlyActive\n    if lyricsOnlyActive then\n        lastStepGrid = nil\n        state._gridBounds = nil\n    end\n\n    -- Helpers used both inside and outside the lyricsOnlyActive / solfegeTextOnlyMode blocks\n    local function gridRC(grid, i)\n        if grid.stepColTbl then\n            local col = grid.stepColTbl[i] or 0\n            local row = grid.stepRowTbl[i] or 0\n            local rowLen = (grid.rowLenTbl and grid.rowLenTbl[row]) or grid.stepsPerRow\n            return col, row, rowLen - col\n        end\n        local col = (i - 1) % grid.stepsPerRow\n        local row = math.floor((i - 1) / grid.stepsPerRow)\n        return col, row, grid.stepsPerRow - col\n    end\n    local function gridRCinv(grid, row, col)\n        if grid.rowStartTbl then\n            local rs = grid.rowStartTbl[row]\n            if rs then return rs + col end\n        end\n        return row * grid.stepsPerRow + col + 1\n    end\n\n    if not lyricsOnlyActive then\n    -- Draw sequence sidebar if open (below pitch meter / sub-header if present)\n    if state.sidebarOpen and not lyricsOnlyActive and not (state.showHandsDuringPlayback and state.isPlaying) then\n        local sidebarSubH  = (not state.singSolfegeMode and state.showBarsBeatsRow and not lyricsOnlyActive) and 24 or 0\n        local sidebarRow2H = (not state.singSolfegeMode and state.showToolsRow and not lyricsOnlyActive) and 28 or 0\n        drawSequenceSidebar(gfx, state, ctx, isMacDesktop, pitchMeterHeight + sidebarRow2H + sidebarSubH + 52 + (state._macFilenameRowH or 0))\n    end\n\n    if not lyricsOnlyActive\n        and not (state.showHandsDuringPlayback and state.isPlaying)\n        and (not effectiveHideSteps or state.useShapeNotes or state.showLyrics or state.showSolfegeLyrics) then\n        local displaySequence = state.sequence\n        local displaySequenceLength = state.sequenceLength\n        if state.isPlaying and ctx.getPlaybackSequence then\n            displaySequence = ctx.getPlaybackSequence()\n        end\n        if state.isPlaying and ctx.getPlaybackLength then\n            displaySequenceLength = ctx.getPlaybackLength()\n        end\n        -- Trim trailing empty/rest steps so + button appears right after last real note,\n        -- but always show at least up to currentStep so a newly added empty step is visible.\n        if not state.isPlaying then\n            local trimLen = 0\n            for i = (displaySequenceLength or 0), 1, -1 do\n                local s = displaySequence[i]\n                if s and ((s.note ~= nil and s.note ~= 13) or (s.notes and #s.notes > 0)) then\n                    trimLen = i\n                    break\n                end\n            end\n            -- Always include the active step, but keep the add cursor separate\n            -- so the + button stays aligned to the real insert position.\n            local cs = state.currentStep or 0\n            local actualLen = displaySequenceLength or 0\n            if cs == actualLen + 1 then\n                trimLen = math.max(trimLen, actualLen)\n            elseif cs > trimLen and cs <= actualLen then\n                trimLen = cs\n            end\n            displaySequenceLength = trimLen\n        end\n        -- Live reorder preview: remap displaySequence to show where step would land\n        local reorderDrag = ctx.reorderDrag\n        if reorderDrag and reorderDrag.active and not state.isPlaying then\n            local from = reorderDrag.stepIndex\n            local to   = reorderDrag.targetIndex or from\n            -- Clamp to valid range\n            local seqLen = displaySequenceLength or 0\n            from = math.max(1, math.min(from, seqLen))\n            to   = math.max(1, math.min(to,   seqLen))\n            if from ~= to then\n                -- Build a shallow copy with the step block moved to target position.\n                -- For stretched steps (length > 1) move the entire block (step + nil continuations).\n                local preview = {}\n                for k = 1, seqLen do preview[k] = displaySequence[k] end\n                local movedStep = preview[from]\n                local stepLen = (movedStep and movedStep.length) or 1\n                local stepSlots = math.ceil(stepLen)  -- integer slot count\n                -- Clamp to so the block fits\n                to = math.max(1, math.min(to, seqLen - stepSlots + 1))\n                if from ~= to then\n                    -- Save block\n                    local block = {}\n                    for i = 1, stepSlots do block[i] = preview[from + i - 1] end\n                    if to > from then\n                        for k = from, to - 1 do preview[k] = preview[k + stepSlots] end\n                    else\n                        for k = from + stepSlots - 1, to + stepSlots, -1 do preview[k] = preview[k - stepSlots] end\n                    end\n                    for i = 1, stepSlots do preview[to + i - 1] = block[i] end\n                end\n                displaySequence = preview\n            end\n        end\n        local gridSidebarOffset = state.sidebarOpen and SIDEBAR_WIDTH or 0\n        local _textSide = state.solfegeTextInputSide or \"bottom\"\n        local _textShownSide = (state.showSolfegeTextInput ~= false)\n        -- Portrait: cap sidebar width at 40% of screen to leave room for the grid\n        local _maxSideW = isPortraitLayout() and math.floor(SCREEN_W * 0.4) or 260\n        if _textShownSide and _textSide == \"left\" then gridSidebarOffset = gridSidebarOffset + math.max(80, math.min(_maxSideW, state.solfegeInputWidth or SOLFEGE_SIDEBAR_W)) end\n        -- Lyric strip: add a small amount of extra gap below each cell (not reducing cell height).\n        -- Step cells stay full height; lyric text sits in the extra gap space below the cell outline.\n        local lyricsVisible = state.showLyrics or state.showSolfegeLyrics\n        local LYRIC_GAP_EXTRA = (state.useShapeNotes or not lyricsVisible) and 0 or 20\n        -- Lyric preview strip: scan for any step with a lyric so we know whether to reserve space.\n        -- Only shown when the per-cell lyric row (LYRIC_GAP_EXTRA) is NOT already visible,\n        -- avoiding redundancy. Hidden in shape-note and lyrics-only modes.\n        local _hasAnyLyric = false\n        if LYRIC_GAP_EXTRA == 0 and not state.useShapeNotes then\n            for _lpi = 1, math.min(displaySequenceLength or 0, 300) do\n                local _lps = displaySequence[_lpi]\n                if _lps and (_lps.lyric or \"\") ~= \"\" then\n                    _hasAnyLyric = true\n                    break\n                end\n            end\n        end\n        local LYRIC_PREVIEW_H = _hasAnyLyric and 16 or 0\n        -- Gap between rows: just enough for lyrics when shown, minimal otherwise\n        local verticalGap = LYRIC_GAP_EXTRA > 0 and (4 + LYRIC_GAP_EXTRA) or 4\n        local subHeaderH = (not state.singSolfegeMode and state.showBarsBeatsRow and not lyricsOnlyActive) and 24 or 0\n        local editRow2H  = (not state.singSolfegeMode and state.showToolsRow and not lyricsOnlyActive) and 28 or 0\n        -- Keep the step grid farther below the header/transport controls.\n        -- Sing mode on Mac has an extra inline mic/keynote control row; add a bit\n        -- more separation so the first timeline row never crowds/overlaps the mic row.\n        local gridTopPadding\n        if isMacDesktop then\n            local fnRowH = state._macFilenameRowH or 0\n            if state.singSolfegeMode then\n                local micRowH = (state.showMicRow ~= false) and 26 or 0\n                gridTopPadding = 72 + micRowH + fnRowH\n            else\n                local micRowH = (state.showMicRow ~= false) and 26 or 0\n                gridTopPadding = 84 + fnRowH + micRowH - editRow2H - subHeaderH\n            end\n        else\n            gridTopPadding = 48 + _safeAreaTop\n        end\n        local startY = state._gridStartY or ((isMacDesktop and 33 or 31) + pitchMeterHeight + editRow2H + subHeaderH + gridTopPadding)\n\n        -- Available screen space for the step grid\n\n        local lyricPanelReserve = 0\n        if _textShownSide and _textSide == \"right\" then lyricPanelReserve = lyricPanelReserve + math.max(80, math.min(_maxSideW, state.solfegeInputWidth or SOLFEGE_SIDEBAR_W)) end\n        local availableWidth = SCREEN_W - 8 - gridSidebarOffset - lyricPanelReserve  -- 8px total margin (4 each side)\n        local _solfegeLines = 0\n        do\n            local b = state.solfegeInputBuffer or \"\"\n            for _ in (b .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do _solfegeLines = _solfegeLines + 1 end\n        end\n        local _textInputShown = (state.showSolfegeTextInput ~= false)\n        local _solfegeAreaH = (_textInputShown and _textSide == \"bottom\") and (solfegeInputAreaH(math.min(math.max(_solfegeLines, 6), MAX_SOLFEGE_VISIBLE_LINES)) + SOLFEGE_BTN_ROW_H) or 0\n        state._solfegeInputAreaH = _solfegeAreaH\n        local _syllableBtnRowH = (state.showSolfegeButtons ~= false and not state.solfegeTextOnlyMode) and SOLFEGE_SYLLABLE_ROW_H or 0\n        local availableHeight = SCREEN_H - startY - 2 - _solfegeAreaH - _syllableBtnRowH - LYRIC_PREVIEW_H - _safeAreaBottom\n\n        -- On small screens, scale down the vertical gap so rows aren't pushed far down.\n        -- This preserves normal row-wrapping while keeping row 1 closer to row 0.\n        if availableHeight < 150 then\n            verticalGap = math.max(8, math.floor(verticalGap * availableHeight / 150))\n        end\n\n        -- Total steps that need to be visible (active steps + optional room for the + button)\n        local addStepIndex = (displaySequenceLength or 0) + 1\n        local showAddStepButton = (state.showAddStepButton ~= false) or (state.currentStep == addStepIndex)\n        local totalSteps = math.max(1, (displaySequenceLength or 0) + (showAddStepButton and 1 or 0))\n\n        -- Preferred cell dimensions — shrink in portrait to fit more steps per row\n        local prefStepWidth\n        if isPortraitLayout() then\n            prefStepWidth = math.max(28, math.min(state.sidebarOpen and 38 or 40, math.floor(availableWidth / 5)))\n        else\n            prefStepWidth = state.sidebarOpen and 42 or 46\n        end\n        local prefStepHeight = state.useShapeNotes and (isMacDesktop and 52 or 48) or 22\n        local horizontalGap = isPortraitLayout() and 4 or 6\n\n        -- Compute columns that fit at preferred size\n        local stepsPerRow = math.max(4, math.floor((availableWidth + horizontalGap) / (prefStepWidth + horizontalGap)))\n\n        -- Optional manual row break: force a wrap after N steps regardless of available width.\n        local manualRowBreak = state.rowBreakAfterStep\n        if state.singleRowGrid then\n            -- Single-line text → force all steps onto one row (expand stepsPerRow if needed)\n            stepsPerRow = math.max(stepsPerRow, (displaySequenceLength or 1))\n        elseif type(manualRowBreak) == \"number\" and manualRowBreak >= 1 then\n            local forcedColumns = math.max(1, math.min(core.maxSteps, math.floor(manualRowBreak)))\n            stepsPerRow = math.min(stepsPerRow, forcedColumns)\n        end\n\n        -- Compute rows needed, then check if they fit; if not, add columns and retry\n        local rowsNeeded = math.ceil(totalSteps / stepsPerRow)\n        local rowsToShow = math.max(1, math.floor((availableHeight + verticalGap) / (prefStepHeight + verticalGap)))\n\n        local hasManualBreak = (type(manualRowBreak) == \"number\" and manualRowBreak >= 1)\n            or (type(manualRowBreak) == \"table\" and #manualRowBreak > 0)\n        if rowsNeeded > rowsToShow then\n            if state.singleRowGrid then\n                -- Single-row mode: expand to fit everything on one row\n                rowsToShow = rowsNeeded\n            elseif hasManualBreak then\n                -- Multi-row with manual break: scrolling handles overflow.\n                -- Keep rowsToShow at natural height so maxScrollRow > 0.\n                -- (no expansion here)\n            else\n                -- No manual break — increase columns to reduce rows\n                stepsPerRow = math.max(stepsPerRow, math.ceil(totalSteps / rowsToShow))\n            end\n        end\n        rowsNeeded = math.ceil(totalSteps / stepsPerRow)\n\n        -- Variable-row layout: when rowBreakAfterStep is an array of absolute break positions,\n        -- compute per-step col/row lookup tables and override stepsPerRow/rowsNeeded.\n        local stepColTbl, stepRowTbl, rowLenTbl, rowStartTbl\n        local varRows = type(manualRowBreak) == \"table\" and #manualRowBreak > 0\n        if varRows then\n            clearTable(_scratchStepCol)\n            clearTable(_scratchStepRow)\n            clearTable(_scratchRowLen)\n            clearTable(_scratchRowStart)\n            stepColTbl  = _scratchStepCol\n            stepRowTbl  = _scratchStepRow\n            rowLenTbl   = _scratchRowLen\n            rowStartTbl = _scratchRowStart\n            local rowNum  = 0\n            local rowStart = 1\n            local bpIdx   = 1\n            rowStartTbl[0] = 1\n            for si = 1, totalSteps do\n                stepRowTbl[si] = rowNum\n                stepColTbl[si] = si - rowStart\n                if bpIdx <= #manualRowBreak and si == manualRowBreak[bpIdx] then\n                    rowLenTbl[rowNum] = si - rowStart + 1\n                    rowNum    = rowNum + 1\n                    rowStart  = si + 1\n                    bpIdx     = bpIdx + 1\n                    rowStartTbl[rowNum] = rowStart\n                end\n            end\n            rowLenTbl[rowNum] = math.max(0, totalSteps - rowStart + 1)\n            rowsNeeded = rowNum + 1\n            local maxLen = 0\n            for r = 0, rowNum do maxLen = math.max(maxLen, rowLenTbl[r] or 0) end\n            stepsPerRow = math.max(1, maxLen)\n        end\n\n        -- Shrink cell height only when NOT using scroll (scroll handles overflow for manual breaks)\n        local stepHeight = prefStepHeight\n        if rowsNeeded > rowsToShow and not hasManualBreak then\n            stepHeight = math.max(14, math.floor((availableHeight - (rowsNeeded - 1) * verticalGap) / rowsNeeded))\n            rowsToShow = rowsNeeded\n        end\n\n        -- If the + button would be the only item on a new row, bump stepsPerRow by 1\n        -- so it sits at the end of the last step row instead of alone at the bottom.\n        -- Normal row-wrapping (when steps actually fill a row) is preserved.\n        -- Skip bump when manual row break is active — it must not override the forced column count.\n        do\n            local addIdx = (displaySequenceLength or 0) + 1\n            local manualActive = state.singleRowGrid or (type(manualRowBreak) == \"number\" and manualRowBreak >= 1)\n                or (type(manualRowBreak) == \"table\" and #manualRowBreak > 0)\n            if not manualActive and addIdx > 1 and (addIdx - 1) % stepsPerRow == 0 then\n                local newSPR = stepsPerRow + 1\n                local newStepW = math.floor((availableWidth - (newSPR - 1) * horizontalGap) / newSPR)\n                if newStepW >= 18 then\n                    stepsPerRow = newSPR\n                    rowsNeeded = math.ceil(addIdx / stepsPerRow)\n                    if rowsNeeded > rowsToShow then\n                        stepHeight = math.max(14, math.floor((availableHeight - (rowsNeeded - 1) * verticalGap) / rowsNeeded))\n                        rowsToShow = rowsNeeded\n                    end\n                end\n            end\n        end\n\n\n        -- Grid vertical scroll offset (clamped to valid range)\n        local maxScrollRow = math.max(0, rowsNeeded - rowsToShow)\n        local gridScrollRow = math.max(0, math.min(state.gridScrollRow or 0, maxScrollRow))\n        state.gridScrollRow = gridScrollRow\n        state._gridRowsNeeded = rowsNeeded\n        state._gridRowsToShow = rowsToShow\n\n        -- Fixed-width cells: use preferred width and scroll horizontally for overflow\n        local stepWidth = prefStepWidth\n        local visibleCols = math.max(1, math.floor((availableWidth + horizontalGap) / (prefStepWidth + horizontalGap)))\n        local maxScrollCol = math.max(0, stepsPerRow - visibleCols)\n        local gridScrollCol = math.max(0, math.min(state.gridScrollCol or 0, maxScrollCol))\n        state.gridScrollCol = gridScrollCol\n        state._gridMaxScrollCol = maxScrollCol\n        state._gridVisibleCols  = visibleCols\n        state._gridStepsPerRow  = stepsPerRow\n        local startX = gridSidebarOffset + 4\n        -- Safety: if stepWidth is non-positive (e.g. degenerate seq length), skip grid render\n        if stepWidth <= 0 then return end\n        -- Text-only mode: skip grid drawing so the text box fills the window\n        if not state.solfegeTextOnlyMode then\n        local hideNoteNames = (state.hideNoteNamesDuringPlayback and state.isPlaying)\n            or (state.singSolfegeMode and (state.hideNoteNamesDuringSing or false))\n        local function shouldHideNoteNamesForStep(stepIndex)\n            if state.singSolfegeMode and (state.hideNoteNamesDuringSing or false) then\n                return true\n            end\n            if not (state.hideNoteNamesDuringPlayback and state.isPlaying) then\n                return false\n            end\n            if state.earTrainingRevealAfterPlayback == false then\n                return true\n            end\n            return stepIndex >= (state.currentPlaybackStep or 1)\n        end\n        local useShapeNoteStaff = state.useShapeNotes\n        local showStepGrid = (not effectiveHideSteps or state.useShapeNotes)\n\n        -- Lyric row sits in the extra gap space BELOW each step cell outline (not inside it).\n        -- noteH == stepHeight since the full cell height is the note cell.\n        -- lyricRowH = gap minus 1px separator; the click zone is this strip just below the cell.\n        local lyricRowH = LYRIC_GAP_EXTRA > 0 and (verticalGap - 1) or 0\n        local noteH = stepHeight\n\n        -- Compute steps-per-bar and steps-per-beat for grid marker lines\n        local gridStepsPerBar  = core.getStepsPerBar(state.meterNumerator, state.meterDenominator, state.stepBeats)\n        local gridStepsPerBeat = core.getStepsPerBeat(state.stepBeats)\n\n        -- Pre-compute per-step x offsets: sub-step cells (length < 1) save space,\n        -- shifting all subsequent steps in the same row leftward.\n        clearTable(_scratchXOffsets)\n        local xOffsets = _scratchXOffsets\n        do\n            local rowShift = 0\n            for i = 1, (displaySequenceLength or 0) do\n                local col = varRows and (stepColTbl[i] or 0) or ((i - 1) % stepsPerRow)\n                if col == 0 then rowShift = 0 end\n                xOffsets[i] = rowShift\n                local s = displaySequence[i]\n                if s then\n                    local len = s.length or 1\n                    if len < 1.0 then\n                        local visualW = math.max(4, math.floor(stepWidth * len))\n                        -- Only absorb the width difference, keeping the normal horizontalGap intact\n                        rowShift = rowShift + (stepWidth - visualW)\n                    end\n                end\n            end\n        end\n\n        -- Add-step button: placed immediately after the last step's visual right edge.\n        local addStepButton = nil\n        if showAddStepButton and addStepIndex >= 1 and addStepIndex <= core.maxSteps then\n            -- Find the visual right edge of the last step in the sequence.\n            local lastIdx = displaySequenceLength or 0\n            local lastRow = lastIdx > 0 and (varRows and (stepRowTbl[lastIdx] or 0) or math.floor((lastIdx - 1) / stepsPerRow)) or 0\n            local buttonRow = lastRow\n            local buttonX\n            if lastIdx == 0 then\n                -- Empty sequence: button at top-left\n                buttonX = startX\n            else\n                -- Walk back to find the last real step (skip nil continuation slots)\n                local lastReal = lastIdx\n                while lastReal > 1 and not displaySequence[lastReal] do\n                    lastReal = lastReal - 1\n                end\n                local lastRealStep = displaySequence[lastReal]\n                local lastRealCol = varRows and (stepColTbl[lastReal] or 0) or ((lastReal - 1) % stepsPerRow)\n                local lastRealRow = varRows and (stepRowTbl[lastReal] or 0) or math.floor((lastReal - 1) / stepsPerRow)\n                local lastRealX = startX + lastRealCol * (stepWidth + horizontalGap) - (xOffsets[lastReal] or 0)\n                local lastLen = lastRealStep and (lastRealStep.length or 1) or 1\n                local lastW\n                if lastLen < 1.0 then\n                    lastW = math.max(4, math.floor(stepWidth * lastLen))\n                else\n                    local lastRowLen = varRows and (rowLenTbl[lastRealRow] or stepsPerRow) or stepsPerRow\n                    local clampedLen = math.min(math.ceil(lastLen), lastRowLen - lastRealCol)\n                    lastW = stepWidth * clampedLen + horizontalGap * (clampedLen - 1)\n                end\n                local rightEdge = lastRealX + lastW + horizontalGap\n                -- If button fits on same row, place it there; otherwise next row\n                local rowEndX = startX + stepsPerRow * (stepWidth + horizontalGap) - horizontalGap\n                if lastRealRow == lastRow and rightEdge + stepWidth <= rowEndX + stepWidth then\n                    buttonRow = lastRealRow\n                    buttonX = rightEdge\n                else\n                    buttonRow = lastRow + 1\n                    buttonX = startX\n                end\n            end\n            local buttonCol = math.floor((buttonX - startX) / (stepWidth + horizontalGap))\n            local buttonVisX = startX + (buttonCol - gridScrollCol) * (stepWidth + horizontalGap)\n            if buttonRow >= gridScrollRow and buttonRow < gridScrollRow + rowsToShow then\n                addStepButton = {\n                    x = buttonVisX,\n                    y = startY + (buttonRow - gridScrollRow) * (stepHeight + verticalGap),\n                    w = stepWidth,\n                    h = noteH,\n                }\n            end\n        end\n\n        -- Publish grid layout for hit-testing (e.g., drag-to-stretch)\n        -- Reuse scratch tables to avoid per-frame allocations.\n        clearTable(_scratchGrid)\n        _scratchGrid.startX = startX\n        _scratchGrid.startY = startY\n        _scratchGrid.stepWidth = stepWidth\n        _scratchGrid.stepHeight = stepHeight\n        _scratchGrid.horizontalGap = horizontalGap\n        _scratchGrid.verticalGap = verticalGap\n        _scratchGrid.stepsPerRow = stepsPerRow\n        _scratchGrid.rowsToShow = rowsToShow\n        _scratchGrid.gridScrollRow = gridScrollRow\n        _scratchGrid.gridScrollCol = gridScrollCol\n        _scratchGrid.visibleCols   = visibleCols\n        _scratchGrid.lyricRowH = lyricRowH\n        _scratchGrid.addStepButton = addStepButton\n        _scratchGrid.displaySequenceLength = displaySequenceLength\n        _scratchGrid.xOffsets = xOffsets\n        _scratchGrid.stepColTbl = stepColTbl\n        _scratchGrid.stepRowTbl = stepRowTbl\n        _scratchGrid.rowLenTbl  = rowLenTbl\n        _scratchGrid.rowStartTbl = rowStartTbl\n        lastStepGrid = _scratchGrid\n        _scratchGridBounds.x = startX\n        _scratchGridBounds.y = startY\n        _scratchGridBounds.w = availableWidth\n        _scratchGridBounds.h = rowsToShow * (stepHeight + verticalGap)\n        state._gridBounds = _scratchGridBounds\n\n        local lastStretchEnd = 0  -- tracks the last slot index covered by a multi-row stretch\n        for i = 1, (displaySequenceLength or 0) do\n            local col = varRows and (stepColTbl[i] or 0) or ((i - 1) % stepsPerRow)\n            local row = varRows and (stepRowTbl[i] or 0) or math.floor((i - 1) / stepsPerRow)\n            local x = startX + (col - gridScrollCol) * (stepWidth + horizontalGap) - (xOffsets[i] or 0)\n            local y = startY + (row - gridScrollRow) * (stepHeight + verticalGap)\n\n            if row >= gridScrollRow and row < gridScrollRow + rowsToShow\n               and col >= gridScrollCol and col < gridScrollCol + visibleCols then\n                -- Beat markers (lighter, only between bar markers)\n                local hasVisibleSlot = displaySequence[i] ~= nil\n                    or i == state.currentStep\n                    or (showAddStepButton and i == addStepIndex)\n                if hasVisibleSlot and showStepGrid and state.showBarLines ~= false then\n                    local isBarStart = gridStepsPerBar and gridStepsPerBar >= 2 and i > 1 and (i - 1) % gridStepsPerBar == 0\n                    local isBeatStart = gridStepsPerBeat and i > 1 and (i - 1) % gridStepsPerBeat == 0 and not isBarStart\n                    -- Draw bar-start accent (1px black)\n                    if isBarStart then\n                        gfx.setColor(0, 0, 0, 255)\n                        if col > 0 then\n                            local lineX = x - math.floor(horizontalGap / 2) - 1\n                            gfx.fillRect(lineX, y - 1, 1, noteH + 2)\n                        else\n                            gfx.fillRect(x, y - 1, math.min(stepWidth, 8), 2)\n                        end\n                    end\n                    -- Draw beat-start accent (1px black)\n                    if isBeatStart then\n                        gfx.setColor(0, 0, 0, 150)\n                        if col > 0 then\n                            local lineX = x - math.floor(horizontalGap / 2)\n                            gfx.fillRect(lineX, y + 2, 1, noteH - 4)\n                        else\n                            gfx.fillRect(x, y - 1, math.min(stepWidth, 4), 1)\n                        end\n                        gfx.setColor(0, 0, 0, 255)\n                    end\n                end\n\n                -- Skip empty slots that are part of a prior step's stretch shadow\n                local stretchOwner = nil\n                if not displaySequence[i] then\n                    -- Same-row lookback (handles sub-row continuations)\n                    for lookback = 1, col do\n                        local ownerIdx = i - lookback\n                        if ownerIdx < 1 then break end\n                        local ownerStep = displaySequence[ownerIdx]\n                        if ownerStep then\n                            local ownerCol = varRows and (stepColTbl[ownerIdx] or 0) or ((ownerIdx - 1) % stepsPerRow)\n                            local ownerRow = varRows and (stepRowTbl[ownerIdx] or 0) or math.floor((ownerIdx - 1) / stepsPerRow)\n                            if ownerRow == row and ownerCol < col and core.getStepLength(ownerStep) > lookback then\n                                stretchOwner = ownerIdx\n                            end\n                            break -- stop at first real step found looking back\n                        end\n                    end\n                    -- Cross-row continuation: covered by a step that started on a previous row\n                    if stretchOwner == nil and i <= lastStretchEnd then\n                        stretchOwner = true\n                    end\n                end\n                if stretchOwner then\n                    -- This slot is visually absorbed by the stretched owner cell; skip it\n                else\n\n                -- Compute the visual width for this step\n                local step = displaySequence[i]\n                local stepLen = core.getStepLength(step)\n                if step and stepLen > 1 then\n                    lastStretchEnd = math.max(lastStretchEnd, i + math.ceil(stepLen) - 1)\n                end\n                local maxLenInRow = (varRows and rowLenTbl and rowLenTbl[row] or stepsPerRow) - col\n                local cellWidth\n                if stepLen < 1.0 then\n                    cellWidth = math.max(4, math.floor(stepWidth * stepLen))\n                else\n                    local clampedLen = math.min(math.ceil(stepLen), maxLenInRow)\n                    cellWidth = stepWidth * clampedLen + horizontalGap * (clampedLen - 1)\n                end\n\n                -- Always draw cell outline for all grid positions\n                local isActive = (i <= displaySequenceLength or i == state.currentStep)\n                -- Loop range: teal bar at top of cells within range\n                local _loopS = state.stepLoopStart\n                local _loopE = state.stepLoopEnd\n                if showStepGrid and _loopS and _loopE and i >= _loopS and i <= _loopE then\n                    -- Full-cell background tint (low alpha so note content stays readable)\n                    gfx.setColor(0, 170, 210, 35)\n                    gfx.fillRect(x + 1, y + 1, cellWidth - 2, noteH - 2)\n                    -- Top accent bar + bracket side bars\n                    gfx.setColor(0, 170, 210, 160)\n                    gfx.fillRect(x + 1, y + 1, cellWidth - 2, 3)\n                    if i == _loopS then\n                        gfx.fillRect(x + 1, y + 1, 3, noteH - 2)\n                    end\n                    if i == _loopE then\n                        gfx.fillRect(x + cellWidth - 4, y + 1, 3, noteH - 2)\n                    end\n                    gfx.setColor(0, 0, 0, 255)\n                end\n                if showStepGrid then\n                    if not useShapeNoteStaff then\n                        drawStepCell(gfx, isMacDesktop, x, y, cellWidth, noteH)\n                    else\n                        local staffSpacing = isMacDesktop and 7 or 6\n                        local staffTop = y + (isMacDesktop and 4 or 5)\n                        for staffLine = 0, 4 do\n                            local staffY = staffTop + (staffLine * staffSpacing)\n                            gfx.drawLine(x + 1, staffY, x + cellWidth - 2, staffY)\n                        end\n                    end\n                end\n\n                if isActive and showStepGrid then\n                    local stepHideNoteNames = shouldHideNoteNamesForStep(i)\n                    -- Playback cursor: blue fill\n                    if state.isPlaying and i == state.currentPlaybackStep then\n                        local expandBy = 2\n                        if not useShapeNoteStaff then\n                            gfx.setColor(50, 120, 220, 255)\n                            fillStepCell(gfx, isMacDesktop, x - expandBy, y - expandBy, cellWidth + expandBy * 2, noteH + expandBy * 2)\n                            gfx.setDrawMode(\"fillWhite\")\n                        end\n                    end\n\n                    -- Edit cursor: solid fill when stopped; amber outline when playing and detached\n                    local editOnPlayback = state.isPlaying and (state.currentStep == state.currentPlaybackStep)\n                    if i == state.currentStep and not editOnPlayback then\n                        if not state.isPlaying then\n                            drawCurrentStepEmphasis(gfx, isMacDesktop, x, y, cellWidth, useShapeNoteStaff and stepHeight or noteH, useShapeNoteStaff)\n                        else\n                            -- Amber 2px outline — note text stays readable inside\n                            if not useShapeNoteStaff then\n                                gfx.setColor(220, 130, 0, 240)\n                                gfx.drawRect(x, y, cellWidth, noteH)\n                                gfx.drawRect(x + 1, y + 1, cellWidth - 2, noteH - 2)\n                                gfx.setColor(0, 0, 0, 255)\n                                gfx.setDrawMode(\"copy\")\n                            end\n                        end\n                    end\n\n                    -- Multi-select highlight: fill selected steps\n                    if state.multiSelectMode and state.selectedSteps and state.selectedSteps[i] then\n                        local fillH = useShapeNoteStaff and stepHeight or noteH\n                        fillStepCell(gfx, isMacDesktop, x + 1, y + 1, cellWidth - 2, fillH - 2)\n                        gfx.setDrawMode(\"fillWhite\")\n                    end\n\n                    if displaySequence[i] and not stepHideNoteNames then\n                        local stepFont = stepWidth < 20 and \"small\" or \"normal\"\n                        gfx.setFont(stepFont)\n\n                        -- Check if this is a chord or single note\n                        local chordNotes = core.getChordNotes(displaySequence[i])\n                        local textX = x\n                        local textCellW = cellWidth\n\n                        if useShapeNoteStaff then\n                            local staffTopPadding = isMacDesktop and 5 or 3\n                            local staffBottomPadding = isMacDesktop and 12 or 10\n                            local glyphHeight = isMacDesktop and math.max(20, stepHeight - 10) or 19\n                            for _, noteData in ipairs(chordNotes) do\n                                local noteCenterY = getShapeStaffNoteY(noteData, y, stepHeight, staffTopPadding, staffBottomPadding)\n                                drawStepNote(gfx, ctx, noteData, textX, noteCenterY - math.floor(glyphHeight / 2), textCellW, 1, true, glyphHeight, isMacDesktop, state.showNoteNames, state.rootNote)\n                            end\n                            drawStepLyric(gfx, core.getStepLyricForView(state, displaySequence[i]), textX, y + stepHeight - 11, textCellW)\n                        elseif #chordNotes > 1 then\n                            -- Chord: left-edge bracket + stacked notes with separators\n                            local bracketW = 2\n                            gfx.fillRect(x + 1, y + 2, bracketW, noteH - 4)\n                            local lineHeight = #chordNotes == 2 and 9 or 6\n                            local totalHeight = #chordNotes * lineHeight - lineHeight\n                            local startTextY = y + math.floor((stepHeight - totalHeight) / 2) - 1\n                            local sepInset = math.max(bracketW + 2, math.floor(textCellW * 0.12))\n                            for j, noteData in ipairs(chordNotes) do\n                                local textY = startTextY + (j - 1) * lineHeight\n                                if j > 1 then\n                                    gfx.fillRect(x + sepInset, math.floor(textY) - 2, textCellW - sepInset * 2, 1)\n                                end\n                                drawStepNote(gfx, ctx, noteData, textX + bracketW + 1, y, textCellW - bracketW - 1, textY - y, state.useShapeNotes, nil, isMacDesktop, state.showNoteNames, state.rootNote)\n                            end\n                        elseif #chordNotes == 1 then\n                            -- Display single note, vertically centered in the note cell\n                            local _, textH = gfx.getTextSize(\"X\")\n                            local textY = math.max(1, math.floor((noteH - textH) / 2))\n                            drawStepNote(gfx, ctx, chordNotes[1], textX, y, textCellW, textY, state.useShapeNotes, nil, isMacDesktop, state.showNoteNames, state.rootNote)\n                        end\n\n                        -- Reset draw mode after drawing text\n                        gfx.setDrawMode(\"copy\")\n                    end\n\n                    -- Draw diagonal line through muted steps (within note cell only)\n                    if displaySequence[i] and displaySequence[i].muted then\n                        local muteH = useShapeNoteStaff and stepHeight or noteH\n                        gfx.drawLine(x + 2, y + 2, x + cellWidth - 2, y + muteH - 2)\n                        gfx.drawLine(x + 2, y + muteH - 2, x + cellWidth - 2, y + 2)\n                    end\n\n                    -- Draw gate bar at the bottom of the step cell\n                    local stepGateVal = displaySequence[i] and displaySequence[i].gate\n                    if stepGateVal ~= nil or state.gateMode then\n                        local gateVal = stepGateVal or 0.9\n                        local barH = 3\n                        local barW = math.max(1, math.floor(cellWidth * gateVal))\n                        gfx.setColor(100, 200, 160, 210)\n                        gfx.fillRect(x, y + noteH - barH, barW, barH)\n                        gfx.setColor(0, 0, 0, 255)\n                    end\n\n                    -- Length badge: small label in top-right corner for non-default step lengths\n                    if displaySequence[i] and not stepHideNoteNames then\n                        local stepLen = core.getStepLength(displaySequence[i])\n                        if math.abs(stepLen - 1.0) > 0.005 then\n                            local lenStr = core.formatNoteLength(stepLen)\n                            gfx.setFont(\"small\")\n                            gfx.setDrawMode(\"copy\")\n                            local lw, lh = gfx.getTextSize(lenStr)\n                            if cellWidth >= lw + 8 then\n                                local badgeX = x + cellWidth - lw - 2\n                                local badgeY = y + 2\n                                gfx.setColor(255, 255, 255, 210)\n                                gfx.fillRect(badgeX - 1, badgeY - 1, lw + 2, lh + 1)\n                                gfx.setColor(80, 80, 80, 255)\n                                gfx.drawText(lenStr, badgeX, badgeY)\n                                gfx.setColor(0, 0, 0, 255)\n                                gfx.setDrawMode(\"copy\")\n                            end\n                        end\n                    end\n\n                    -- Roman numeral badge: bottom-left corner of chord steps\n                    if state.showRomanNumerals and displaySequence[i] and not stepHideNoteNames then\n                        local chordNotes = core.getChordNotes(displaySequence[i])\n                        if #chordNotes >= 2 then\n                            local rn = core.getChordRomanNumeral(chordNotes, state.solfegeScale)\n                            if rn then\n                                gfx.setFont(\"small\")\n                                gfx.setDrawMode(\"copy\")\n                                local rnW, rnH = gfx.getTextSize(rn)\n                                if cellWidth >= rnW + 4 then\n                                    local badgeX = x + 2\n                                    local badgeY = y + noteH - rnH - 1\n                                    gfx.setColor(220, 220, 255, 210)\n                                    gfx.fillRect(badgeX - 1, badgeY - 1, rnW + 2, rnH + 1)\n                                    gfx.setColor(60, 60, 160, 255)\n                                    gfx.drawText(rn, badgeX, badgeY)\n                                    gfx.setColor(0, 0, 0, 255)\n                                    gfx.setDrawMode(\"copy\")\n                                end\n                            end\n                        end\n                    end\n\n                    -- Edge-zone stretch handles: brighter drag hint on desktop hover\n                    if isMacDesktop and not state.isPlaying and displaySequence[i] then\n                        local mx = state.mouseX or -9999\n                        local my = state.mouseY or -9999\n                        if my >= y and my < y + noteH and mx >= x and mx < x + cellWidth then\n                            local edgeW = math.max(6, math.floor(stepWidth * 0.35))\n                            local canEdge = cellWidth >= math.max(14, edgeW * 2 + 2)\n                            if canEdge then\n                                local relX = mx - x\n                                local onLeft = relX < edgeW\n                                local onRight = relX >= cellWidth - edgeW\n                                if onLeft or onRight then\n                                    local handleH = math.min(noteH - 6, 10)\n                                    local handleY = y + math.floor((noteH - handleH) / 2)\n                                    gfx.setDrawMode(\"copy\")\n                                    gfx.setColor(0, 0, 0, 160)\n                                    if onRight then\n                                        gfx.fillRect(x + cellWidth - 3, handleY, 2, handleH)\n                                    else\n                                        gfx.fillRect(x + 1, handleY, 2, handleH)\n                                    end\n                                    gfx.setColor(0, 0, 0, 255)\n                                end\n                            end\n                        end\n                    end\n\n                    -- Draw lyric below cell outline (non-shape-note mode)\n                    if not useShapeNoteStaff and lyricRowH > 0 and displaySequence[i] then\n                        local lyricY = y + noteH + 1  -- top of gap, matching lyric mode and click-zone origin\n                        if state.lyricEditingStepIndex == i then\n                            gfx.setFont(\"normal\")\n                            drawEditableStepLyric(gfx, state, i, x, lyricY, cellWidth)\n                        else\n                            gfx.setFont(\"small\")\n                            local lyricText = core.getStepLyricForView(state, displaySequence[i])\n                            if lyricText ~= \"\" then\n                                drawStepLyric(gfx, lyricText, x, lyricY, cellWidth)\n                            end\n                        end\n                    end\n\n                    if state.singSolfegeMode then\n                        local isCurrentPlaybackStep = state.isPlaying and i == state.currentPlaybackStep\n                        local currentStepIndex = state.isPlaying and state.currentPlaybackStep or state.currentStep\n                        local pitchStatus = nil\n                        local symbol = nil\n\n                        -- Current step: show live pitch status (only when actually singing)\n                        if i == currentStepIndex and state.pitchMatchStatus and (state.micLevel or 0) > 0.3 then\n                            pitchStatus = state.pitchMatchStatus\n                            if state.pitchMatchStatus == \"close\" then\n                                symbol = \"o\"\n                            end\n                            -- \"match\" will fill the step instead of showing a symbol\n                        -- Past steps: show saved result\n                        elseif state.singSolfegeStepResults then\n                            local singResult = state.singSolfegeStepResults[i]\n                            if singResult then\n                                pitchStatus = singResult\n                                if singResult == \"close\" then\n                                    symbol = \"o\"\n                                end\n                                -- \"correct\" will fill the step instead of showing a symbol\n                            end\n                        end\n\n                        -- Fill step for correct/match pitch\n                        if pitchStatus == \"match\" or pitchStatus == \"correct\" then\n                            if not isCurrentPlaybackStep then\n                                -- Use hatching pattern for past \"correct\" steps on desktop\n                                -- to distinguish from the solid playback highlight\n                                if isMacDesktop and pitchStatus == \"correct\" then\n                                    -- Horizontal line hatching pattern (every 2px)\n                                    for row = 0, stepHeight - 3, 2 do\n                                        gfx.drawLine(x + 2, y + 1 + row, x + cellWidth - 3, y + 1 + row)\n                                    end\n                                else\n                                    fillStepCell(gfx, isMacDesktop, x + 1, y + 1, cellWidth - 2, stepHeight - 2)\n                                end\n                                -- Redraw note text in white so it's visible on filled background\n                                if displaySequence[i] and not stepHideNoteNames then\n                                    gfx.setDrawMode(\"fillWhite\")\n                                    gfx.setFont(\"bold\")\n                                    local chordNotes = core.getChordNotes(displaySequence[i])\n                                    if #chordNotes == 1 then\n                                        drawStepNote(gfx, ctx, chordNotes[1], x, y, cellWidth, 4, state.useShapeNotes, nil, isMacDesktop, state.showNoteNames, state.rootNote)\n                                    elseif #chordNotes > 1 then\n                                        local bracketW = 2\n                                        gfx.fillRect(x + 1, y + 2, bracketW, stepHeight - 4)\n                                        local lineHeight = #chordNotes == 2 and 9 or 6\n                                        local totalHeight = #chordNotes * lineHeight - lineHeight\n                                        local startTextY = y + math.floor((stepHeight - totalHeight) / 2) - 1\n                                        local sepInset = math.max(bracketW + 2, math.floor(cellWidth * 0.12))\n                                        for j, noteData in ipairs(chordNotes) do\n                                            local textY = startTextY + (j - 1) * lineHeight\n                                            if j > 1 then\n                                                gfx.fillRect(x + sepInset, math.floor(textY) - 2, cellWidth - sepInset * 2, 1)\n                                            end\n                                            drawStepNote(gfx, ctx, noteData, x + bracketW + 1, y, cellWidth - bracketW - 1, textY - y, state.useShapeNotes, nil, isMacDesktop, state.showNoteNames, state.rootNote)\n                                        end\n                                    end\n                                    gfx.setDrawMode(\"copy\")\n                                end\n                            end\n                        end\n\n                        if symbol then\n                            gfx.setFont(\"bold\")\n                            if isCurrentPlaybackStep then\n                                gfx.setDrawMode(\"fillWhite\")\n                            end\n                            local symbolWidth = gfx.getTextSize(symbol)\n                            local symbolX = x + cellWidth - symbolWidth - 2\n                            local symbolY = y + 2\n                            gfx.drawText(symbol, symbolX, symbolY)\n                            gfx.setDrawMode(\"copy\")\n                        end\n\n                        -- Draw hold progress bar at bottom of current step\n                        if i == currentStepIndex and state.isPlaying and not state.singSolfegeRestStep then\n                            local holdMs = state.pitchHoldMs or 0\n                            local holdTarget = state.pitchHoldTargetMs or 300\n                            local holdProgress = math.min(1, holdMs / holdTarget)\n                            if holdProgress > 0 then\n                                local barHeight = 3\n                                local barY = y + stepHeight - barHeight - 1\n                                local barWidth = math.floor((cellWidth - 4) * holdProgress)\n                                if barWidth > 0 then\n                                    if isCurrentPlaybackStep then\n                                        -- Draw in white on filled background\n                                        gfx.setDrawMode(\"fillWhite\")\n                                        gfx.fillRect(x + 2, barY, barWidth, barHeight)\n                                        gfx.setDrawMode(\"copy\")\n                                    else\n                                        gfx.fillRect(x + 2, barY, barWidth, barHeight)\n                                    end\n                                end\n                            end\n                        end\n                    end\n\n                    -- Always reset draw mode: the playback highlight sets fillWhite for white-on-black\n                    -- text rendering; if the step has no note content, the reset inside the note block\n                    -- never runs, leaving subsequent cell outlines drawn in white (invisible).\n                    gfx.setDrawMode(\"copy\")\n                end\n\n                if isActive and not showStepGrid and lyricRowH > 0 and displaySequence[i] then\n                    if state.lyricEditingStepIndex == i then\n                        gfx.setFont(\"normal\")\n                        drawEditableStepLyric(gfx, state, i, x, y + 1, cellWidth)\n                    else\n                        gfx.setFont(\"small\")\n                        local lyricText = core.getStepLyricForView(state, displaySequence[i])\n                        if lyricText ~= \"\" then\n                            drawStepLyric(gfx, lyricText, x, y + 1, cellWidth)\n                        end\n                    end\n                end\n                end -- else not stretchOwner\n\n                -- Paragraph break separator: thin gray line below this row\n                if (state.solfegeShowBreaks ~= false) and displaySequence[i] and displaySequence[i].paragraphEnd then\n                    local lineY = y + noteH + math.floor(verticalGap / 2)\n                    gfx.setColor(140, 140, 140, 180)\n                    gfx.fillRect(startX, lineY, SCREEN_W - startX - 4, 1)\n                end\n            end\n        end\n\n        if showStepGrid and addStepButton then\n            local ab = addStepButton\n            local cx = ab.x + math.floor(ab.w / 2)\n            local cy = ab.y + math.floor(ab.h / 2)\n            local plusSize = math.max(5, math.floor(math.min(ab.w, ab.h) * 0.35))\n            local mx, my = state.mouseX, state.mouseY\n            local hovered = mx and my and mx >= ab.x and mx < ab.x + ab.w and my >= ab.y and my < ab.y + ab.h\n            local isCurrentAddStep = (state.currentStep == addStepIndex)\n            -- Cell fill: brighter when hovered or selected as the current add cursor.\n            gfx.setColor((hovered or isCurrentAddStep) and 80 or 50, (hovered or isCurrentAddStep) and 80 or 50, (hovered or isCurrentAddStep) and 80 or 50, 255)\n            gfx.fillRect(ab.x, ab.y, ab.w, ab.h)\n            -- Border: bright white border always visible, thicker on the active add cursor.\n            gfx.setColor(200, 200, 200, 255)\n            gfx.drawRect(ab.x, ab.y, ab.w, ab.h)\n            if isCurrentAddStep then\n                gfx.drawRect(ab.x + 1, ab.y + 1, ab.w - 2, ab.h - 2)\n            end\n            -- Draw + icon: fully opaque white\n            gfx.setColor(255, 255, 255, 255)\n            gfx.drawLine(cx - plusSize, cy, cx + plusSize, cy)\n            gfx.drawLine(cx, cy - plusSize, cx, cy + plusSize)\n        end\n\n        -- Lyric preview strip: read-only, column-aligned, one row at a time.\n        -- Shows syllables for the row containing the current/playback step, aligned to\n        -- their grid column x-positions. Only visible when per-cell lyric rows are off.\n        if LYRIC_PREVIEW_H > 0 then\n            local stripY = startY + availableHeight + 1\n            local spanX = stepWidth + horizontalGap\n            local stripW = stepsPerRow * spanX - horizontalGap\n            -- Background\n            gfx.setColor(246, 246, 250, 255)\n            gfx.fillRect(startX, stripY, stripW, LYRIC_PREVIEW_H - 1)\n            -- Top separator line\n            gfx.setColor(190, 190, 195, 255)\n            gfx.fillRect(startX, stripY, stripW, 1)\n            -- Determine the row whose lyrics to show (follows edit cursor, not playback head)\n            local focusStep = math.max(1, math.min(\n                state.currentStep or 1,\n                displaySequenceLength or 1))\n            local focusRow = varRows and (stepRowTbl and stepRowTbl[focusStep] or 0)\n                             or math.floor((focusStep - 1) / stepsPerRow)\n            focusRow = math.max(gridScrollRow, math.min(gridScrollRow + rowsToShow - 1, focusRow))\n            local focusCol = varRows and (stepColTbl and stepColTbl[focusStep] or 0)\n                             or ((focusStep - 1) % stepsPerRow)\n            -- Compute step range for the focus row\n            local rowFirst, rowLast\n            if varRows and rowStartTbl and rowLenTbl then\n                rowFirst = rowStartTbl[focusRow] or 1\n                rowLast  = rowFirst + (rowLenTbl[focusRow] or 0) - 1\n            else\n                rowFirst = focusRow * stepsPerRow + 1\n                rowLast  = math.min(rowFirst + stepsPerRow - 1, displaySequenceLength or 0)\n            end\n            -- Draw each syllable at its column x-position\n            gfx.setFont(\"small\")\n            gfx.setDrawMode(\"copy\")\n            for si = rowFirst, rowLast do\n                local s = displaySequence[si]\n                if s then\n                    local lyric = core.getStepLyricForView(state, s)\n                    if lyric ~= \"\" then\n                        local sCol = varRows and (stepColTbl and stepColTbl[si] or 0) or ((si - 1) % stepsPerRow)\n                        local lx = startX + sCol * spanX\n                        -- Keep playback highlighting scoped to the step grid itself.\n                        if not state.isPlaying and sCol == focusCol then\n                            gfx.setColor(210, 210, 235, 220)\n                            gfx.fillRect(lx, stripY + 1, stepWidth, LYRIC_PREVIEW_H - 2)\n                        end\n                        gfx.setColor(35, 35, 35, 255)\n                        gfx.drawText(lyric, lx + 1, stripY + 3)\n                    end\n                end\n            end\n            -- Row indicator when the sequence spans multiple rows\n            if rowsNeeded > 1 then\n                local rowLabel = (focusRow + 1) .. \"/\" .. rowsNeeded\n                local rlw = gfx.getTextSize(rowLabel)\n                gfx.setColor(155, 155, 155, 255)\n                gfx.drawText(rowLabel, startX + stripW - rlw - 2, stripY + 3)\n            end\n            gfx.setDrawMode(\"copy\")\n            gfx.setColor(0, 0, 0, 255)\n        end\n    end\n    -- Grid scrollbar: thin indicator on right edge when more rows exist than are visible\n    if lastStepGrid and (lastStepGrid._rowsNeeded or state._gridRowsNeeded or 0) > (lastStepGrid._rowsToShow or state._gridRowsToShow or 1) then\n        local rn = state._gridRowsNeeded or 1\n        local rt = state._gridRowsToShow or 1\n        local sr = state.gridScrollRow or 0\n        local sbX = (lastStepGrid.startX or 0) + (lastStepGrid.stepWidth or 0) * (lastStepGrid.stepsPerRow or 1) + ((lastStepGrid.stepsPerRow or 1) - 1) * (lastStepGrid.horizontalGap or 0) + 2\n        local sbY = lastStepGrid.startY or 0\n        local sbH = rt * ((lastStepGrid.stepHeight or 18) + (lastStepGrid.verticalGap or 4))\n        local thumbH = math.max(6, math.floor(sbH * rt / rn))\n        local maxSR = math.max(1, rn - rt)\n        local thumbY = sbY + (sr > 0 and math.floor((sbH - thumbH) * sr / maxSR) or 0)\n        gfx.setColor(210, 210, 210, 220)\n        gfx.fillRect(sbX, sbY, 3, sbH)\n        gfx.setColor(120, 120, 120, 220)\n        gfx.fillRect(sbX, thumbY, 3, thumbH)\n    end\n\n    -- Reset color after the + button sets it to white for the icon; all subsequent\n    -- rendering (hover nubs, hold-pending bar, drag ghost) must draw in black.\n    gfx.setColor(0, 0, 0, 255)\n\n    -- Playhead position marker: a small downward-pointing triangle rendered just\n    -- above the current step cell.  It tracks currentPlaybackStep while the\n    -- sequence is playing or paused, and currentStep while editing (stopped).\n    -- The triangle gives an unambiguous read-head that is visible across all\n    -- rows of a wrapped grid at a glance.\n    if lastStepGrid and showStepGrid and not useShapeNoteStaff then\n        local grid = lastStepGrid\n        local phStep = (state.isPlaying or state.isPaused)\n            and state.currentPlaybackStep\n            or state.currentStep\n        if phStep and phStep >= 1 then\n            local col, row = gridRC(grid, phStep)\n            local visRow = row - (grid.gridScrollRow or 0)\n            local _gsc = grid.gridScrollCol or 0\n            if visRow >= 0 and visRow < grid.rowsToShow\n               and col >= _gsc and col < _gsc + (grid.visibleCols or grid.stepsPerRow) then\n                local cellX = grid.startX + (col - _gsc) * (grid.stepWidth + grid.horizontalGap)\n                local cellY = grid.startY + visRow * (grid.stepHeight + grid.verticalGap)\n                local cx = cellX + math.floor(grid.stepWidth / 2)\n                -- Downward triangle: wide base at top, single-pixel tip just above cell\n                local triH     = 4   -- total height in pixels\n                local triMaxHW = 3   -- half-width of the base (base = 7 px wide)\n                local tipY = cellY - 1\n                if state.isPlaying then\n                    gfx.setColor(30, 140, 255, 230)   -- bright blue while playing\n                elseif state.isPaused then\n                    gfx.setColor(30, 100, 200, 200)   -- muted blue while paused\n                else\n                    gfx.setColor(70, 70, 80, 190)     -- dark gray while editing\n                end\n                -- Draw each row of the triangle (dy=0 is the wide base at the top)\n                for dy = 0, triH - 1 do\n                    local hw = math.floor(triMaxHW * (triH - 1 - dy) / (triH - 1))\n                    gfx.fillRect(cx - hw, tipY - triH + 1 + dy, hw * 2 + 1, 1)\n                end\n                -- 1-px needle extending from the cell top edge downward a few pixels\n                gfx.fillRect(cx, cellY, 1, math.min(4, grid.stepHeight - 2))\n                gfx.setColor(0, 0, 0, 255)\n            end\n        end\n    end\n\n    -- Draw edge-zone hover hint on hovered step (shows stretch zones)\n    local mx, my = state.mouseX, state.mouseY\n    if mx and my and lastStepGrid and not state.isPlaying and not lyricsOnlyActive and (not effectiveHideSteps or state.useShapeNotes) then\n        local grid = lastStepGrid\n        local hoverCol = math.floor((mx - grid.startX) / (grid.stepWidth + grid.horizontalGap)) + (grid.gridScrollCol or 0)\n        local hoverVisRow = math.floor((my - grid.startY) / (grid.stepHeight + grid.verticalGap))\n        local hoverRow = hoverVisRow + (grid.gridScrollRow or 0)\n        local hoverRowMaxCol = (grid.rowLenTbl and grid.rowLenTbl[hoverRow]) or grid.stepsPerRow\n        if hoverCol >= 0 and hoverCol < hoverRowMaxCol and hoverVisRow >= 0 and hoverVisRow < grid.rowsToShow then\n            local cellY = grid.startY + hoverVisRow * (grid.stepHeight + grid.verticalGap)\n            if my >= cellY and my < cellY + grid.stepHeight then\n                local hoverIdx = gridRCinv(grid, hoverRow, hoverCol)\n                -- Resolve the step owner (handles stretched cells whose continuation slots are nil)\n                local ownerIdx, ownerCol\n                if state.sequence[hoverIdx] then\n                    ownerIdx, ownerCol = hoverIdx, hoverCol\n                else\n                    for lookback = 1, hoverCol do\n                        local cIdx = hoverIdx - lookback\n                        if cIdx < 1 then break end\n                        local cStep = state.sequence[cIdx]\n                        if cStep then\n                            local cCol, cRow = gridRC(grid, cIdx)\n                            if cRow == hoverRow and core.getStepLength(cStep) > lookback then\n                                ownerIdx, ownerCol = cIdx, cCol\n                            end\n                            break\n                        end\n                    end\n                end\n                if ownerIdx then\n                    local ownerStep = state.sequence[ownerIdx]\n                    local ownerLen = core.getStepLength(ownerStep)\n                    local _, _, maxLenInRow = gridRC(grid, ownerIdx)\n                    local xOff = grid.xOffsets and grid.xOffsets[ownerIdx] or 0\n                    local cellX = grid.startX + (ownerCol - (grid.gridScrollCol or 0)) * (grid.stepWidth + grid.horizontalGap) - xOff\n                    local cellWidth\n                    if ownerLen < 1.0 then\n                        cellWidth = math.max(4, math.floor(grid.stepWidth * ownerLen))\n                    else\n                        local clampedLen = math.min(math.ceil(ownerLen), maxLenInRow)\n                        cellWidth = grid.stepWidth * clampedLen + grid.horizontalGap * (clampedLen - 1)\n                    end\n                    if mx >= cellX and mx < cellX + cellWidth then\n                        local edgeW = math.max(6, math.floor(grid.stepWidth * 0.35))\n                        local relX = mx - cellX\n                        local midY = cellY + math.floor(grid.stepHeight / 2)\n                        -- On hover, show full-height solid bar over the dots\n                        local nubH = grid.stepHeight - 2\n                        local inEdge = false\n                        if relX < edgeW then\n                            gfx.fillRect(cellX + 1, cellY + 1, 2, nubH)\n                            inEdge = true\n                        elseif relX >= cellWidth - edgeW then\n                            gfx.fillRect(cellX + cellWidth - 3, cellY + 1, 2, nubH)\n                            inEdge = true\n                        end\n                        -- Show current note length label while hovering an edge zone\n                        if inEdge and ownerStep then\n                            local lenLabel = core.formatNoteLength(ownerLen)\n                            gfx.setFont(\"small\")\n                            local lw, lh = gfx.getTextSize(lenLabel)\n                            local lx = cellX + math.floor((cellWidth - lw) / 2)\n                            local ly = cellY - lh - 2\n                            gfx.setColor(240, 240, 255, 220)\n                            gfx.fillRect(lx - 2, ly - 1, lw + 4, lh + 2)\n                            gfx.setColor(60, 60, 80, 255)\n                            gfx.drawText(lenLabel, lx, ly)\n                            gfx.setColor(0, 0, 0, 255)\n                        end\n                    end\n                end\n            end\n        end\n    end\n\n    -- Delete mode: red tint on hovered step to preview what will be removed\n    if state.editMode == \"delete\" and mx and my and lastStepGrid then\n        local grid = lastStepGrid\n        local hoverCol = math.floor((mx - grid.startX) / (grid.stepWidth + grid.horizontalGap)) + (grid.gridScrollCol or 0)\n        local hoverVisRow = math.floor((my - grid.startY) / (grid.stepHeight + grid.verticalGap))\n        local hoverRow = hoverVisRow + (grid.gridScrollRow or 0)\n        local hoverRowMaxCol = (grid.rowLenTbl and grid.rowLenTbl[hoverRow]) or grid.stepsPerRow\n        if hoverCol >= 0 and hoverCol < hoverRowMaxCol and hoverVisRow >= 0 and hoverVisRow < grid.rowsToShow then\n            local cellY = grid.startY + hoverVisRow * (grid.stepHeight + grid.verticalGap)\n            if my >= cellY and my < cellY + grid.stepHeight then\n                local hoverIdx = gridRCinv(grid, hoverRow, hoverCol)\n                if state.sequence[hoverIdx] then\n                    local hoverCellX = grid.startX + (hoverCol - (grid.gridScrollCol or 0)) * (grid.stepWidth + grid.horizontalGap)\n                    gfx.setColor(255, 60, 60, 70)\n                    gfx.fillRect(hoverCellX + 1, cellY + 1, grid.stepWidth - 2, grid.stepHeight - 2)\n                    gfx.setColor(0, 0, 0, 255)\n                end\n            end\n        end\n    end\n\n    -- Draw hold-to-reorder activation ring (border thickens 1→3px as user holds)\n    local holdPending = ctx.holdPending\n    local holdThresholdS = ctx.holdThresholdS or 0.35\n    if holdPending and holdPending.active and lastStepGrid then\n        local grid = lastStepGrid\n        local si = holdPending.stepIndex\n        local held = os.clock() - (holdPending.startTime or os.clock())\n        local progress = math.min(1.0, held / holdThresholdS)\n        local hCol, hRow = gridRC(grid, si)\n        local _gsc = grid.gridScrollCol or 0\n        if hRow < grid.rowsToShow\n           and hCol >= _gsc and hCol < _gsc + (grid.visibleCols or grid.stepsPerRow) then\n            local hx = grid.startX + (hCol - _gsc) * (grid.stepWidth + grid.horizontalGap)\n            local hy = grid.startY + hRow * (grid.stepHeight + grid.verticalGap)\n            local lw = math.max(1, math.ceil(progress * 3))\n            local pad = lw + 1\n            gfx.setLineWidth(lw)\n            gfx.drawRect(hx - pad, hy - pad, grid.stepWidth + pad * 2, grid.stepHeight + pad * 2)\n            gfx.setLineWidth(1)\n        end\n    end\n\n    -- Draw drag-to-reorder overlay: insertion caret + mouse-following ghost\n    local reorderDrag = ctx.reorderDrag\n    if reorderDrag and reorderDrag.active and lastStepGrid then\n        local grid = lastStepGrid\n        local from = reorderDrag.stepIndex\n        local target = reorderDrag.targetIndex or from\n        local step = state.sequence[from]\n        local stepLen = (step and step.length) or 1\n        local seqLen = state.sequenceLength or 1\n        local mx = state.mouseX or 0\n        local my = state.mouseY or 0\n\n        -- Insertion caret: 2px vertical bar in the gap before the target slot\n        do\n            local caretRow, caretX\n            local _gsc = grid.gridScrollCol or 0\n            local caretIdx = math.max(1, math.min(target, seqLen + 1))\n            if caretIdx <= seqLen then\n                local col\n                col, caretRow = gridRC(grid, caretIdx)\n                local cellX = grid.startX + (col - _gsc) * (grid.stepWidth + grid.horizontalGap)\n                -- Center caret in the horizontal gap; if first col, place just left of it\n                caretX = col > _gsc and (cellX - math.ceil(grid.horizontalGap / 2) - 1) or (cellX - 2)\n            else\n                -- After last step: right edge of last cell\n                local lastCol\n                lastCol, caretRow = gridRC(grid, seqLen)\n                local lastCellX = grid.startX + (lastCol - _gsc) * (grid.stepWidth + grid.horizontalGap)\n                caretX = lastCellX + grid.stepWidth + 1\n            end\n            if caretRow and caretRow < grid.rowsToShow then\n                local caretY = grid.startY + caretRow * (grid.stepHeight + grid.verticalGap) - 2\n                gfx.fillRect(caretX, caretY, 2, grid.stepHeight + 4)\n            end\n        end\n\n        -- Mouse-following ghost: step cell centered on cursor\n        if step then\n            local clampedLen = math.min(math.ceil(stepLen < 1 and 1 or stepLen), grid.stepsPerRow)\n            local ghostWidth = grid.stepWidth * clampedLen + grid.horizontalGap * (clampedLen - 1)\n            local ghostX = mx - math.floor(ghostWidth / 2)\n            local ghostY = my - math.floor(grid.stepHeight / 2)\n            -- Filled cell\n            fillStepCell(gfx, isMacDesktop, ghostX, ghostY, ghostWidth, grid.stepHeight)\n            -- 2px border ring for definition\n            gfx.setLineWidth(2)\n            gfx.drawRect(ghostX - 2, ghostY - 2, ghostWidth + 4, grid.stepHeight + 4)\n            gfx.setLineWidth(1)\n            -- Note text in white\n            gfx.setDrawMode(\"fillWhite\")\n            local chordNotes = core.getChordNotes(step)\n            if #chordNotes == 1 then\n                drawStepNote(gfx, ctx, chordNotes[1], ghostX, ghostY, ghostWidth, 4, state.useShapeNotes, nil, isMacDesktop, state.showNoteNames, state.rootNote)\n            else\n                local bracketW = 2\n                gfx.fillRect(ghostX + 1, ghostY + 2, bracketW, grid.stepHeight - 4)\n                local lineHeight = #chordNotes == 2 and 9 or 6\n                local totalHeight = #chordNotes * lineHeight - lineHeight\n                local startTextY = math.floor((grid.stepHeight - totalHeight) / 2) - 1\n                local sepInset = math.max(bracketW + 2, math.floor(ghostWidth * 0.12))\n                for j, noteData in ipairs(chordNotes) do\n                    local textY = startTextY + (j - 1) * lineHeight\n                    if j > 1 then\n                        gfx.fillRect(ghostX + sepInset, ghostY + math.floor(textY) - 2, ghostWidth - sepInset * 2, 1)\n                    end\n                    drawStepNote(gfx, ctx, noteData, ghostX + bracketW + 1, ghostY, ghostWidth - bracketW - 1, textY, state.useShapeNotes, nil, isMacDesktop, state.showNoteNames, state.rootNote)\n                end\n            end\n            gfx.setDrawMode(\"copy\")\n        end\n        end -- if not state.solfegeTextOnlyMode\n    end\n\n    -- Key-hold length overlay: bold label above the step being stretched\n    if state._numKeyPressStep and lastStepGrid then\n        local grid = lastStepGrid\n        local si = state._numKeyPressStep\n        local step = state.sequence and state.sequence[si]\n        local col, row, maxLenInRow = gridRC(grid, si)\n        local _gsc = grid.gridScrollCol or 0\n        if step and row < grid.rowsToShow\n           and col >= _gsc and col < _gsc + (grid.visibleCols or grid.stepsPerRow) then\n            local xOff = (grid.xOffsets and grid.xOffsets[si]) or 0\n            local cellX = grid.startX + (col - _gsc) * (grid.stepWidth + grid.horizontalGap) - xOff\n            local cellY = grid.startY + row * (grid.stepHeight + grid.verticalGap)\n            local stepLen = step.length or 1\n            local cellWidth\n            if stepLen < 1.0 then\n                cellWidth = math.max(4, math.floor(grid.stepWidth * stepLen))\n            else\n                local clampedLen = math.min(math.ceil(stepLen), maxLenInRow)\n                cellWidth = grid.stepWidth * clampedLen + grid.horizontalGap * (clampedLen - 1)\n            end\n            -- Bold length label centered above the cell\n            local lenLabel = core.formatNoteLength(stepLen)\n            gfx.setFont(\"bold\")\n            local lw, lh = gfx.getTextSize(lenLabel)\n            local lx = cellX + math.floor((cellWidth - lw) / 2)\n            local ly = cellY - lh - 5\n            lx = math.max(2, math.min(lx, SCREEN_W - lw - 6))\n            ly = math.max(2, ly)\n            gfx.setColor(50, 80, 210, 235)\n            gfx.fillRect(lx - 4, ly - 2, lw + 8, lh + 4)\n            gfx.setColor(255, 255, 255, 255)\n            gfx.drawText(lenLabel, lx, ly)\n            gfx.setFont(\"normal\")\n            gfx.setColor(0, 0, 0, 255)\n        end\n    end\n\n    end\n\n    -- Step duration dropdown\n    if state.stepDurDropdownOpen and state._stepDurHeaderX then\n        local options = core.stepBeatsOptions\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local menuW = 38\n        local menuH = #options * itemH + (DROPDOWN_VERTICAL_PADDING * 2)\n        local menuX = state._stepDurHeaderX\n        local menuY = 81\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local btns = {}\n        gfx.setFont(\"normal\")\n        for i, opt in ipairs(options) do\n            local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n            local label = core.getStepBeatsShortLabel(opt)\n            local lw = gfx.getTextSize(label)\n            local isActive = math.abs((state.stepBeats or 1) - opt) < 0.001\n            if isActive then\n                gfx.fillRoundRect(menuX + 2, itemY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            gfx.drawText(label, menuX + math.floor((menuW - lw) / 2), itemY + getDropdownTextYOffset(gfx, itemH))\n            gfx.setDrawMode(\"copy\")\n            btns[i] = {x = menuX, y = itemY, w = menuW, h = itemH, value = opt}\n        end\n        gfx.setFont(\"bold\")\n        state._stepDurDropdownBtns = btns\n    else\n        state._stepDurDropdownBtns = nil\n    end\n\n    -- Meter dropdown\n    if state.meterDropdownOpen and state._meterHeaderX then\n        local options = core.timeSignatureOptions\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local menuW = 36\n        local menuH = #options * itemH + (DROPDOWN_VERTICAL_PADDING * 2)\n        local menuX = state._meterHeaderX\n        local menuY = 81\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local btns = {}\n        gfx.setFont(\"normal\")\n        for i, sig in ipairs(options) do\n            local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n            local label = sig.numerator .. \"/\" .. sig.denominator\n            local lw = gfx.getTextSize(label)\n            local isActive = sig.numerator == state.meterNumerator and sig.denominator == state.meterDenominator\n            if isActive then\n                gfx.fillRoundRect(menuX + 2, itemY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            gfx.drawText(label, menuX + math.floor((menuW - lw) / 2), itemY + getDropdownTextYOffset(gfx, itemH))\n            gfx.setDrawMode(\"copy\")\n            btns[i] = {x = menuX, y = itemY, w = menuW, h = itemH, numerator = sig.numerator, denominator = sig.denominator}\n        end\n        gfx.setFont(\"bold\")\n        state._meterDropdownBtns = btns\n    else\n        state._meterDropdownBtns = nil\n    end\n\n    -- Draw syllable dropdown menu overlay (anchored to the clicked step)\n    if state.syllableDropdownOpen and state.syllableDropdownAnchorX then\n        local SCALE_STEP_OPTIONS = getScaleStepOptions(state.solfegeScale)\n        local dropdownOptions = {}\n        for _, noteIdx in ipairs(SCALE_STEP_OPTIONS) do\n            dropdownOptions[#dropdownOptions + 1] = { note = noteIdx, label = ctx.solfegeNotes[noteIdx + 1] }\n        end\n\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local menuW = 64\n        local octaveControlH = 20\n        local octaveControlGap = 4\n        local menuH = #dropdownOptions * itemH + (DROPDOWN_VERTICAL_PADDING * 2) + octaveControlH + octaveControlGap\n        local menuX = (state.syllableDropdownAnchorX or 0) - math.floor(menuW / 2)\n        local anchorY = state.syllableDropdownAnchorY or 106\n        -- Resolve the current note directly from state (displayNote is out of scope here)\n        local stepData = state.sequence[state.currentStep]\n        local currentNote = state.syllableDropdownSelectedNote\n        if currentNote == nil then\n            if stepData and not core.isChord(stepData) and stepData.lyric == \"_\" then\n                currentNote = \"_\"\n            else\n                currentNote = (stepData and not core.isChord(stepData))\n                                    and stepData.note or state.selectedNote\n            end\n        end\n        -- Find which item in the list is currently selected (0-based index)\n        local selectedIdx = 0\n        local highlightedIndex = state.syllableDropdownSelection\n        if highlightedIndex and highlightedIndex >= 1 and highlightedIndex <= #dropdownOptions then\n            selectedIdx = highlightedIndex - 1\n        else\n            for i, option in ipairs(dropdownOptions) do\n                if currentNote == option.note then\n                    selectedIdx = i - 1  -- 0-based\n                    break\n                end\n            end\n        end\n        -- Position so the selected item's text aligns with the step text.\n        local stepTop = state.syllableDropdownStepTop\n        local stepHeight = state.syllableDropdownStepHeight\n        local dropdownTextYOffset = getDropdownTextYOffset(gfx, itemH)\n        local activeTextTop\n        if stepTop ~= nil and stepHeight ~= nil then\n            local _, stepTextHeight = gfx.getTextSize(\"X\")\n            local stepTextYOffset = math.max(1, math.floor((stepHeight - stepTextHeight) / 2))\n            activeTextTop = stepTop + stepTextYOffset\n        else\n            activeTextTop = anchorY - math.floor(itemH / 2) + dropdownTextYOffset\n        end\n        local menuY = activeTextTop - dropdownTextYOffset - DROPDOWN_VERTICAL_PADDING - selectedIdx * itemH\n        -- Clamp vertically to screen\n        menuY = math.max(2, math.min(SCREEN_H - menuH - 2, menuY))\n        -- Clamp horizontally to screen\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        -- White background to cover what's behind\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        -- Border\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        -- Items\n        local btns = {}\n        gfx.setFont(\"normal\")\n        for i, option in ipairs(dropdownOptions) do\n            local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n            local label = option.label\n            local lw = gfx.getTextSize(label)\n            local isActive = (state.syllableDropdownSelection == i)\n            if state.syllableDropdownSelection == nil then\n                isActive = (currentNote == option.note and (option.octave == nil or option.octave == (state.currentOctave or 4)))\n            end\n            if isActive then\n                gfx.fillRoundRect(menuX + 2, itemY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            gfx.drawText(label, menuX + math.floor((menuW - lw) / 2), itemY + getDropdownTextYOffset(gfx, itemH))\n            gfx.setDrawMode(\"copy\")\n            btns[i] = {x = menuX, y = itemY, w = menuW, h = itemH, note = option.note, octave = option.octave}\n        end\n\n        local controlsY = menuY + DROPDOWN_VERTICAL_PADDING + (#dropdownOptions * itemH) + octaveControlGap\n        local controlBtnW = 18\n        local controlBtnH = octaveControlH - 2\n        local minusBtn = {x = menuX + 4, y = controlsY, w = controlBtnW, h = controlBtnH, delta = -1}\n        local plusBtn = {x = menuX + menuW - controlBtnW - 4, y = controlsY, w = controlBtnW, h = controlBtnH, delta = 1}\n        local flashUntil = state._syllableDropdownOctaveFlashUntil or 0\n        local flashDir = (flashUntil > os.clock()) and state._syllableDropdownOctaveFlashDir or nil\n        local octaveLabel = \"O\" .. tostring(state.currentOctave or 4)\n        local octaveLabelW = gfx.getTextSize(octaveLabel)\n\n        for _, btn in ipairs({minusBtn, plusBtn}) do\n            gfx.drawRoundRect(btn.x, btn.y, btn.w, btn.h, 3)\n            if flashDir == btn.delta then\n                gfx.fillRoundRect(btn.x + 1, btn.y + 1, btn.w - 2, btn.h - 2, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            local label = btn.delta < 0 and \"-\" or \"+\"\n            local labelW, labelH = gfx.getTextSize(label)\n            gfx.drawText(label, btn.x + math.floor((btn.w - labelW) / 2), btn.y + math.floor((btn.h - labelH) / 2) - 1)\n            gfx.setDrawMode(\"copy\")\n        end\n        gfx.drawText(octaveLabel, menuX + math.floor((menuW - octaveLabelW) / 2), controlsY + math.max(0, math.floor((controlBtnH - select(2, gfx.getTextSize(octaveLabel))) / 2)) - 1)\n\n        gfx.setFont(\"bold\")\n        state._syllableDropdownBtns = btns\n        state._syllableDropdownOctaveBtns = {minusBtn, plusBtn}\n    else\n        state._syllableDropdownBtns = nil\n        state._syllableDropdownOctaveBtns = nil\n    end\n\n    -- Top-menu dropdowns are rendered at the END of the function (just before the tooltip)\n    -- so they always appear on top of the solfege panel, even in lyrics-only mode where\n    -- the solfege area fills most of the screen. Clear stale btns when dropdowns are closed.\n    if not state.editDropdownOpen then state._editDropdownBtns = nil end\n    if not state.fileDropdownOpen then state._fileDropdownBtns = nil; state._fileSubmenuBtns = nil end\n    if not state.pathPopupOpen then state._pathPopupBtns = nil end\n    if not state.displayOptionsDropdownOpen then state._displayOptionsDropdownBtns = nil; state._viewSubmenuBtns = nil end\n    if not state.inputSourcesDropdownOpen then state._inputSourcesDropdownBtns = nil end\n    if not state.solfegeScaleDropdownOpen then state._solfegeScaleDropdownBtns = nil end\n    if not state.keynoteDropdownOpen then state._keynoteDropdownBtns = nil end\n\n    -- Step context menu popup (right-click on a step to insert before/after)\n    if state.stepContextMenu then\n        local menu = state.stepContextMenu\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local menuX = menu.anchorX\n        local menuY = menu.anchorY\n        local btns = {}\n        gfx.setFont(\"normal\")\n\n        if menu.mode == \"lengths\" then\n            -- Length picker: 2-column grid of common note lengths\n            local COMMON_LENGTHS = {0.125, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0}\n            local colW = 44\n            local numCols = 2\n            local menuW = colW * numCols + 2\n            local numRows = math.ceil(#COMMON_LENGTHS / numCols)\n            local backH = itemH\n            local menuH = DROPDOWN_VERTICAL_PADDING + backH + numRows * itemH + DROPDOWN_VERTICAL_PADDING\n            if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n            if menuX < 2 then menuX = 2 end\n            if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n            gfx.setDrawMode(\"fillWhite\")\n            gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n            gfx.setDrawMode(\"copy\")\n            gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n            -- Back button\n            local backY = menuY + DROPDOWN_VERTICAL_PADDING\n            local backLabel = \"\\xe2\\x86\\x90 Back\"\n            local lw = gfx.getTextSize(backLabel)\n            gfx.drawText(backLabel, menuX + math.floor((menuW - lw) / 2), backY + getDropdownTextYOffset(gfx, itemH))\n            btns[1] = {x = menuX, y = backY, w = menuW, h = itemH, action = \"back\"}\n            -- Divider line\n            gfx.drawLine(menuX + 4, backY + itemH, menuX + menuW - 4, backY + itemH)\n            -- Length grid\n            local currentLen = (state.sequence[menu.stepIndex] or {}).length or 1\n            for i, len in ipairs(COMMON_LENGTHS) do\n                local col = (i - 1) % numCols\n                local row = math.floor((i - 1) / numCols)\n                local ix = menuX + 1 + col * colW\n                local iy = menuY + DROPDOWN_VERTICAL_PADDING + backH + row * itemH\n                local label = core.formatNoteLength(len)\n                local isActive = math.abs(len - currentLen) < 0.001\n                if isActive then\n                    gfx.fillRoundRect(ix + 1, iy, colW - 2, itemH, 2)\n                    gfx.setDrawMode(\"fillWhite\")\n                end\n                local lw2 = gfx.getTextSize(label)\n                gfx.drawText(label, ix + math.floor((colW - lw2) / 2), iy + getDropdownTextYOffset(gfx, itemH))\n                gfx.setDrawMode(\"copy\")\n                btns[#btns + 1] = {x = ix, y = iy, w = colW, h = itemH, action = {length = len}}\n            end\n        else\n            -- Main menu\n            local items = {\"Insert Before\", \"Insert After\", \"Set Length \\xe2\\x96\\xba\"}\n            local menuW = 95\n            local menuH = #items * itemH + DROPDOWN_VERTICAL_PADDING * 2\n            if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n            if menuX < 2 then menuX = 2 end\n            if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n            gfx.setDrawMode(\"fillWhite\")\n            gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n            gfx.setDrawMode(\"copy\")\n            gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n            local actions = {\"before\", \"after\", \"setLength\"}\n            for i, label in ipairs(items) do\n                local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n                local lw = gfx.getTextSize(label)\n                gfx.drawText(label, menuX + math.floor((menuW - lw) / 2), itemY + getDropdownTextYOffset(gfx, itemH))\n                btns[i] = {x = menuX, y = itemY, w = menuW, h = itemH, action = actions[i]}\n            end\n        end\n\n        gfx.setFont(\"bold\")\n        state._stepContextMenuBtns = btns\n    else\n        state._stepContextMenuBtns = nil\n    end\n\n    -- lyric notes panel is now embedded in the solfege input area (below button strip)\n    state._lyricNotesDetachBtn = nil\n    if not state.lyricNotesPanelOpen then\n        state._lyricNotesPanelBounds = nil\n        state._lyricNotesTokenBounds = nil\n    end\n\n    local lyricTokenDrag = ctx.lyricTokenDrag\n    if lyricTokenDrag and lyricTokenDrag.active and lyricTokenDrag.token then\n        local label = tostring(lyricTokenDrag.token)\n        local tw = gfx.getTextSize(label)\n        local chipW = tw + 8\n        local chipH = 12\n        local chipX = math.floor((lyricTokenDrag.x or 0) - chipW / 2)\n        local chipY = math.floor((lyricTokenDrag.y or 0) - chipH - 2)\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRect(chipX, chipY, chipW, chipH)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRect(chipX, chipY, chipW, chipH)\n        gfx.drawText(label, chipX + 4, chipY + 2)\n    end\n\n    -- Solfege text input (bottom bar or left/right sidebar)\n    -- When solfegeTextInputSide == \"window\", the panel renders in a separate OS window;\n    -- clear bounds so main-window hit-tests don't fire, then skip drawing here.\n    if state.showSolfegeTextInput ~= false\n       and (state.solfegeTextInputSide or \"bottom\") == \"window\" then\n        state._solfegeInputBounds = nil\n        state._solfegePanelBounds = nil\n        state._solfegeDockBtns = nil\n        state._solfegeModeBtns = nil\n        state._solfegeCopyBtn = nil\n        state._solfegeCutBtn = nil\n        state._solfegePasteBtn = nil\n        state._solfegeFloatTitleBar = nil\n        state._solfegeFloatResizeHandle = nil\n        state._solfegeDragHandle = nil\n    end\n    if not ctx.showWelcomeScreen\n       and not state.showingModeSelect\n       and not state.showingTemplateBrowser\n       and state.showSolfegeTextInput ~= false\n       and (state.solfegeTextInputSide or \"bottom\") ~= \"window\" then\n        local buf = state.solfegeInputBuffer or \"\"\n        local lines = {}\n        for line in (buf .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do table.insert(lines, line) end\n        if #lines == 0 then lines = {\"\"} end\n        local lineCount = math.max(1, #lines)\n        local active = state.solfegeInputActive\n        local textSide = state.solfegeTextInputSide or \"bottom\"\n        local lyricsOnlyActive = (state.solfegeTextMode ~= nil)\n        SOLFEGE_LINE_H = solfegeLineHeight(state)\n        local textFontStyle = solfegeTextFontStyle(state)\n\n        -- Determine area geometry + button strip above the white box\n        local boxX, boxY, boxW, boxH, maxVisLines, btnStripX, btnStripY, btnStripW\n        local panelBounds = nil\n        local lyricNotesH = 0  -- extra height added when lyric notes panel is embedded\n        local lnSideW = 0      -- width reserved for side-by-side inline LN panel\n        if textSide == \"left\" or textSide == \"right\" then\n            local sideMaxW = isPortraitLayout() and math.floor(SCREEN_W * 0.4) or 260\n            local sidebarW = math.max(80, math.min(sideMaxW, state.solfegeInputWidth or SOLFEGE_SIDEBAR_W))\n            local sbX = textSide == \"left\" and 0 or (SCREEN_W - sidebarW)\n            local sbY = (state._gridStartY or 50) - 2\n            local sbH = SCREEN_H - sbY\n            btnStripX = sbX\n            btnStripY = sbY\n            btnStripW = sidebarW\n            boxX = sbX + SOLFEGE_PAD\n            boxY = sbY + SOLFEGE_BTN_ROW_H\n            boxW = sidebarW - SOLFEGE_PAD * 2\n            boxH = sbH - SOLFEGE_BTN_ROW_H - SOLFEGE_PAD\n            panelBounds = {x = sbX, y = sbY, w = sidebarW, h = sbH}\n            maxVisLines = math.max(1, math.floor((boxH - SOLFEGE_BOX_PAD * 2) / SOLFEGE_LINE_H))\n            drawThemeRect(gfx, state, \"panel\", sbX, sbY, sidebarW, sbH)\n            -- Resize drag handle on inner edge\n            local handleX = textSide == \"left\" and (sbX + sidebarW - 4) or sbX\n            local mx2, my2 = state.mouseX or -1, state.mouseY or -1\n            local hovering = mx2 >= handleX and mx2 < handleX + 4 and my2 >= sbY and my2 < sbY + sbH\n            gfx.setColor(hovering and 120 or 190, hovering and 160 or 200, hovering and 220 or 220, 255)\n            gfx.fillRect(handleX, sbY, 4, sbH)\n            state._solfegeDragHandle = {x = handleX, y = sbY, w = 4, h = sbH, side = textSide}\n            state._solfegeFloatTitleBar = nil\n            state._solfegeFloatResizeHandle = nil\n        elseif textSide == \"float\" then\n            local FLOAT_TITLE_H = 14\n            local floatX = math.max(0, math.min(SCREEN_W - 100, state.solfegeFloatX or 40))\n            local floatY = math.max(0, math.min(SCREEN_H - 60, state.solfegeFloatY or 60))\n            local floatW = math.max(140, math.min(400, state.solfegeFloatW or 240))\n            local floatH = math.max(80 + FLOAT_TITLE_H, math.min(SCREEN_H - floatY, state.solfegeFloatH or 180))\n            -- Shadow\n            gfx.setColor(0, 0, 0, 60)\n            gfx.fillRect(floatX + 3, floatY + 3, floatW, floatH)\n            -- Body background\n            drawThemeRect(gfx, state, \"panel\", floatX, floatY + FLOAT_TITLE_H, floatW, floatH - FLOAT_TITLE_H)\n            -- Title bar\n            setThemeColor(gfx, state, \"buttonActive\")\n            gfx.fillRect(floatX, floatY, floatW, FLOAT_TITLE_H)\n            -- Title label\n            gfx.setFont(\"normal\")\n            gfx.setColor(255, 255, 255, 255)\n            local tlabel = \"Text Input\"\n            local tlw, tlh = gfx.getTextSize(tlabel)\n            gfx.drawText(tlabel, floatX + math.floor((floatW - tlw) / 2), floatY + math.floor((FLOAT_TITLE_H - tlh) / 2))\n            -- Border\n            setThemeColor(gfx, state, \"panelLine\")\n            gfx.drawRect(floatX, floatY, floatW, floatH)\n            -- Resize handle (bottom-right corner triangle)\n            local rhSize = 9\n            local rhX = floatX + floatW - rhSize - 1\n            local rhY = floatY + floatH - rhSize - 1\n            gfx.setColor(100, 130, 170, 200)\n            gfx.fillRect(rhX, rhY, rhSize, rhSize)\n            gfx.setColor(50, 80, 120, 200)\n            gfx.drawRect(rhX, rhY, rhSize, rhSize)\n            state._solfegeFloatTitleBar   = {x = floatX, y = floatY, w = floatW, h = FLOAT_TITLE_H}\n            state._solfegeFloatResizeHandle = {x = rhX, y = rhY, w = rhSize, h = rhSize}\n            -- Geometry for button strip and text box (inside the panel body)\n            btnStripX = floatX\n            btnStripY = floatY + FLOAT_TITLE_H\n            btnStripW = floatW\n            boxX = floatX + SOLFEGE_PAD\n            boxY = floatY + FLOAT_TITLE_H + SOLFEGE_BTN_ROW_H\n            boxW = floatW - SOLFEGE_PAD * 2\n            boxH = floatH - FLOAT_TITLE_H - SOLFEGE_BTN_ROW_H - SOLFEGE_PAD\n            panelBounds = {x = floatX, y = floatY, w = floatW, h = floatH}\n            maxVisLines = math.max(1, math.floor((boxH - SOLFEGE_BOX_PAD * 2) / SOLFEGE_LINE_H))\n            state._solfegeDragHandle = nil\nelse  -- bottom\n    -- lyricNotesH stays 0: LN panel floats as an overlay above the strip instead of\n    -- expanding the solfege area (which would push areaY up into the grid).\n    local chatReservedH = 0\n    if state.cmdChatOpen then\n        local maxChatH = math.max(0, math.min(math.floor(SCREEN_H * 0.6), SCREEN_H - 6))\n        chatReservedH = math.min(maxChatH, math.max(48, math.floor(state.cmdChatBottomH or 100)))\n        if chatReservedH < 36 then chatReservedH = 0 end\n    end\n    local minH = solfegeInputAreaH(3) + SOLFEGE_BTN_ROW_H\n    local defaultH = solfegeInputAreaH(math.min(math.max(lineCount, 6), MAX_SOLFEGE_VISIBLE_LINES)) + SOLFEGE_BTN_ROW_H\n    local contentTopY = state._gridStartY or (50 + _safeAreaTop)\n    if lyricsOnlyActive then contentTopY = math.max(_safeAreaTop, contentTopY - 12) end\n    if chatReservedH > 0 then\n        chatReservedH = math.min(chatReservedH, math.max(0, SCREEN_H - contentTopY - minH))\n    end\n    local _syllBtnReserve = (state.showSolfegeButtons ~= false and not state.solfegeTextOnlyMode) and SOLFEGE_SYLLABLE_ROW_H or 0\n    local bottomLimit = SCREEN_H - chatReservedH - _syllBtnReserve - _safeAreaBottom\n    -- syllable buttons sit at absolute bottom; text panel sits above them\n    local maxH = state.solfegeTextOnlyMode\n        and (bottomLimit - contentTopY)\n        or math.floor(bottomLimit * 0.7)\n    local areaH = state.solfegeTextOnlyMode\n        and maxH\n        or math.max(minH, math.min(maxH, state.solfegeBottomH or defaultH))\n    local areaY = lyricsOnlyActive and contentTopY or (bottomLimit - areaH)\n    if lyricsOnlyActive then\n        areaH = bottomLimit - areaY\n    end\n    btnStripX = 0\n    btnStripY = areaY\n    btnStripW = SCREEN_W\n    panelBounds = {x = 0, y = areaY, w = SCREEN_W, h = areaH}\n    boxX = SOLFEGE_PAD\n    boxY = areaY + SOLFEGE_BTN_ROW_H\n    boxW = SCREEN_W - SOLFEGE_PAD * 2\n    boxH = areaH - SOLFEGE_BTN_ROW_H - SOLFEGE_PAD\n    -- Side-by-side LN panel: carve width from the left of the text box\n    if state.lyricNotesPanelOpen and not state.lyricNotesDetached then\n        local totalW = boxW\n        local splitRatio = math.max(0.2, math.min(0.65, state.lnSplitRatio or 0.38))\n        lnSideW = math.max(50, math.min(math.floor(totalW * 0.65), math.floor(totalW * splitRatio)))\n        boxX = boxX + lnSideW + 4\n        boxW = boxW - lnSideW - 4\n    end\n    maxVisLines = math.max(1, math.floor((boxH - SOLFEGE_BOX_PAD * 2) / SOLFEGE_LINE_H))\n    drawThemeRect(gfx, state, \"panel\", 0, areaY, SCREEN_W, areaH)\n    -- Resize handle strip at top edge\n    local mx2, my2 = state.mouseX or -1, state.mouseY or -1\n    local hovering = my2 >= areaY - 1 and my2 < areaY + 5\n    gfx.setColor(hovering and 100 or 180, hovering and 140 or 195, hovering and 200 or 210, 255)\n    gfx.fillRect(0, areaY, SCREEN_W, 4)\n    state._solfegeBottomResizeHandle = {x = 0, y = areaY - 2, w = SCREEN_W, h = 8}\n    state._solfegeDragHandle = nil\n    state._solfegeFloatTitleBar = nil\n    state._solfegeFloatResizeHandle = nil\n        end\n        state._solfegePanelBounds = panelBounds\n\n        -- Button strip: top row for mode/dock, second row for text tools\n    do\n        local btnW, btnH = _touchMode and 48 or 20, _touchMode and 40 or 16\n        local btnGap = _touchMode and 10 or 5\n        local rowTopY = btnStripY + 4\n        local rowBottomY = rowTopY\n        gfx.setFont(\"normal\")\n\n        local mx2h = state.mouseX or -1\n        local my2h = state.mouseY or -1\n        local function drawBtn(x, y, w, h, isActive)\n            local hov = mx2h >= x and mx2h < x+w and my2h >= y and my2h < y+h\n            if isActive then\n                setThemeColor(gfx, state, \"buttonActive\")\n            elseif hov then\n                setThemeColor(gfx, state, \"buttonHover\")\n            else\n                setThemeColor(gfx, state, \"button\")\n            end\n            gfx.fillRect(x, y, w, h)\n            if isActive then\n                setThemeColor(gfx, state, \"buttonActiveBorder\")\n            elseif hov then\n                setThemeColor(gfx, state, \"buttonBorder\")\n            else\n                setThemeColor(gfx, state, \"buttonBorder\")\n            end\n            gfx.drawRect(x, y, w, h)\n            if isActive then\n                gfx.setColor(255, 255, 255, 255)\n            else\n                setThemeColor(gfx, state, \"text\")\n            end\n        end\n        local function drawCloseBtn(x, y, w, h)\n            local hov = mx2h >= x and mx2h < x+w and my2h >= y and my2h < y+h\n            gfx.setColor(hov and 210 or 208, hov and 90 or 208, hov and 90 or 212, 230)\n            gfx.fillRect(x, y, w, h)\n            gfx.setColor(hov and 160 or 148, hov and 60 or 148, hov and 60 or 156, 255)\n            gfx.drawRect(x, y, w, h)\n            gfx.setColor(hov and 255 or 52, hov and 255 or 52, hov and 255 or 62, 255)\n        end\n\n        local curMode = state.solfegeTextMode or \"both\"\n        local modeDefs = {\n        }\n        local modeBtns = {}\n        local mX = btnStripX + SOLFEGE_PAD\n        for _, md in ipairs(modeDefs) do\n            local bw = md.w or btnW\n            local isActive = (curMode == md.mode)\n            drawBtn(mX, rowTopY, bw, btnH, isActive)\n            local lw, lh = gfx.getTextSize(md.label)\n            gfx.drawText(md.label, mX + math.floor((bw - lw) / 2), rowTopY + math.floor((btnH - lh) / 2))\n            modeBtns[#modeBtns + 1] = {x = mX, y = rowTopY, w = bw, h = btnH, mode = md.mode}\n            mX = mX + bw + btnGap\n        end\n        state._solfegeModeBtns = modeBtns\n\n        state._solfegelyricNotesPanelBtn = nil\n\n        state._solfegeLyricsWindowBtn = nil\n\n        state._solfegeMainWindowBtn = nil\n        state._solfegeDockBtns = nil\n\n        state._solfegeSizeBtn = nil\n        state._solfegeSizeMenuItems = nil\n        local toolX = btnStripX + SOLFEGE_PAD\n\n        state._solfegeBreakToggleBtn = nil\n\n        state._solfegeSpellBtn = nil\n\n        state._solfegeCopyBtn = nil\n        state._solfegeCutBtn = nil\n        state._solfegePasteBtn = nil\n        state._solfegeNoteLengthBtn = nil\n        state._solfegeOctToggleBtn = nil\n\n        state._solfegeTemplateBtn = nil\n        state._solfegeTemplateMenuOpen = false\n        state._solfegeAllOptionsOpen = false\n        state._solfegeAllOptionsBtn = nil\n        state._solfegeKeyMinusBtn = nil\n        state._solfegeKeyPlusBtn = nil\n        state._solfegeOctMinusBtn = nil\n        state._solfegeOctPlusBtn = nil\n\n        state._solfegeTextOnlyBtn = nil\n\n        state._solfegeParagraphBtn = nil\n    end\n\n    -- Reset split handle when panel is closed\n    if not (state.lyricNotesPanelOpen and not state.lyricNotesDetached) then\n        state._lnSplitHandle = nil\n        state._lnSplitDragActive = nil\n    end\n    -- Lyric notes panel: side-by-side with the text box (left column), same height.\n    -- No vertical expansion so the step grid above stays visible.\n    if state.lyricNotesPanelOpen and not state.lyricNotesDetached and lnSideW > 0 then\n        local lnH = boxH\n        local lnX, lnY, lnW = SOLFEGE_PAD, boxY, lnSideW\n        local lnPad = 4\n        local lnLineH = 14\n        local lnInputY = lnY + 18\n        local lnInputW = lnW - lnPad * 2\n        local lnMaxLines = 2\n        local lnBlink = (math.floor(os.clock() * 2) % 2 == 0)\n        -- Background\n        gfx.setColor(255, 255, 255, 255)\n        gfx.fillRect(lnX, lnY, lnW, lnH)\n        gfx.setColor(150, 165, 210, 255)\n        gfx.drawRect(lnX, lnY, lnW, lnH)\n        gfx.setColor(170, 180, 215, 255)\n        gfx.drawLine(lnX + 1, lnY + lnH - 1, lnX + lnW - 2, lnY + lnH - 1)\n        -- Title\n        gfx.setFont(\"normal\")\n        gfx.setColor(50, 65, 110, 255)\n        gfx.drawText(\"Lyric Notes\", lnX + lnPad, lnY + 3)\n        -- Window / close buttons\n        local lnBtnGap = 3\n        local lnDetachW, lnDetachH = 18, 12\n        local lnDetachX = lnX + lnW - lnPad - lnDetachW - lnBtnGap - 14\n        local lnDetachY = lnY + 3\n        gfx.setColor(200, 200, 210, 230)\n        gfx.fillRect(lnDetachX, lnDetachY, lnDetachW, lnDetachH)\n        gfx.setColor(130, 130, 150, 255)\n        gfx.drawRect(lnDetachX, lnDetachY, lnDetachW, lnDetachH)\n        gfx.setColor(80, 80, 100, 255)\n        local lnDtw = gfx.getTextSize(\"⊡\")\n        gfx.drawText(\"⊡\", lnDetachX + math.floor((lnDetachW - lnDtw) / 2), lnDetachY + 1)\n        state._lyricNotesDetachBtn = {x = lnDetachX, y = lnDetachY, w = lnDetachW, h = lnDetachH}\n        local lnCloseW, lnCloseH = 14, 12\n        local lnCloseX = lnX + lnW - lnCloseW - lnPad\n        local lnCloseY = lnY + 3\n        gfx.setColor(200, 200, 210, 230)\n        gfx.fillRect(lnCloseX, lnCloseY, lnCloseW, lnCloseH)\n        gfx.setColor(130, 130, 150, 255)\n        gfx.drawRect(lnCloseX, lnCloseY, lnCloseW, lnCloseH)\n        gfx.setColor(80, 80, 100, 255)\n        local lnCxw = gfx.getTextSize(\"×\")\n        gfx.drawText(\"×\", lnCloseX + math.floor((lnCloseW - lnCxw) / 2), lnCloseY + 1)\n        state._lyricNotesCloseBtn = {x = lnCloseX, y = lnCloseY, w = lnCloseW, h = lnCloseH}\n        -- Word-wrap the input buffer\n        local lnBuf = state.lyricNotesBuffer or \"\"\n        local lnCursorPos = state.lyricNotesCursor\n        local lnLines = {\"\"}\n        local lnLineBufStarts = {1}\n        for lnWs, lnWord in lnBuf:gmatch(\"()(%S+)\") do\n            local lnCand = lnLines[#lnLines] == \"\" and lnWord or (lnLines[#lnLines] .. \" \" .. lnWord)\n            if gfx.getTextSize(lnCand) <= lnInputW then\n                lnLines[#lnLines] = lnCand\n            else\n                table.insert(lnLines, lnWord)\n                lnLineBufStarts[#lnLines] = lnWs\n            end\n        end\n        if lnBuf:match(\"%s$\") and lnLines[#lnLines] ~= \"\" then\n            table.insert(lnLines, \"\")\n            lnLineBufStarts[#lnLines] = #lnBuf + 1\n        end\n        local lnCursorLine = #lnLines\n        local lnCharInLine = #lnLines[#lnLines]\n        if lnCursorPos ~= nil then\n            for i = 1, #lnLines - 1 do\n                if lnCursorPos < lnLineBufStarts[i + 1] - 1 then lnCursorLine = i; break end\n            end\n            lnCharInLine = math.max(0, math.min(lnCursorPos - (lnLineBufStarts[lnCursorLine] - 1), #lnLines[lnCursorLine]))\n        end\n        local lnStart = math.max(1, lnCursorLine - lnMaxLines + 1)\n        local lnLineMap = {}\n        for i = lnStart, #lnLines do\n            lnLineMap[i - lnStart + 1] = {bufStart = (lnLineBufStarts[i] or 1) - 1, text = lnLines[i]}\n        end\n        state._lyricNotesInputLineMap = lnLineMap\n        state._lyricNotesInputX = lnX + lnPad\n        state._lyricNotesInputLineH = lnLineH\n        state._lyricNotesInputY = lnInputY\n        -- Selection highlight (drawn before cursor character is inserted)\n        do\n            local lnSelLo, lnSelHi = nil, nil\n            local lnSA, lnSF = state.lyricNotesSelAnchor, state.lyricNotesSelFocus\n            if lnSA ~= nil and lnSF ~= nil and lnSA ~= lnSF then\n                lnSelLo = math.min(lnSA, lnSF); lnSelHi = math.max(lnSA, lnSF)\n            end\n            if lnSelLo then\n                local _, lnFontH = gfx.getTextSize(\"|\")\n                local lnSelH = (lnFontH and lnFontH > 0) and lnFontH or (lnLineH - 1)\n                gfx.setColor(100, 160, 230, 120)\n                for i = lnStart, math.min(lnStart + lnMaxLines - 1, #lnLines) do\n                    local bufStart = (lnLineBufStarts[i] or 1) - 1\n                    local lineText = lnLines[i]\n                    local lineEnd = bufStart + #lineText\n                    if lnSelLo < lineEnd and lnSelHi > bufStart then\n                        local sc = math.max(0, lnSelLo - bufStart)\n                        local ec = math.min(#lineText, lnSelHi - bufStart)\n                        local x1 = lnX + lnPad + gfx.getTextSize(lineText:sub(1, sc))\n                        local x2 = lnX + lnPad + gfx.getTextSize(lineText:sub(1, ec))\n                        if x2 > x1 then\n                            gfx.fillRect(x1, lnInputY + (i - lnStart) * lnLineH, x2 - x1, lnSelH)\n                        end\n                    end\n                end\n            end\n        end\n        -- Draw text lines then a vertical line cursor (matching solfege text box style)\n        gfx.setColor(20, 30, 60, 255)\n        for i = lnStart, math.min(lnStart + lnMaxLines - 1, #lnLines) do\n            if lnLines[i] ~= \"\" then\n                gfx.drawText(lnLines[i], lnX + lnPad, lnInputY + (i - lnStart) * lnLineH)\n            end\n        end\n        local lnCursorVisible = state.lyricNotesInputActive\n            and (lnCursorPos ~= nil or lnBlink)  -- solid mid-text, blinking at end\n            and lnCursorLine >= lnStart and lnCursorLine <= lnStart + lnMaxLines - 1\n        if lnCursorVisible then\n            local _, lnFontH2 = gfx.getTextSize(\"|\")\n            local lnCurH = (lnFontH2 and lnFontH2 > 0) and lnFontH2 or (lnLineH - 1)\n            local lnCursorX = lnX + lnPad + (lnCharInLine > 0 and gfx.getTextSize(lnLines[lnCursorLine]:sub(1, lnCharInLine)) or 0)\n            local lnCursorY = lnInputY + (lnCursorLine - lnStart) * lnLineH\n            gfx.setColor(30, 30, 80, 255)\n            gfx.fillRect(lnCursorX, lnCursorY, _touchMode and 2 or 1, lnCurH)\n        end\n        state._lyricNotesPanelBounds = {\n            x = lnX, y = lnY, width = lnW, height = lnH,\n            inputX = lnX + lnPad, inputY = lnInputY,\n            inputW = lnInputW, inputH = lnMaxLines * lnLineH,\n        }\n        state._lyricNotesTokenBounds = {}\n        -- Split drag handle in the 4px gap between LN panel and text box\n        local splitHX = lnX + lnW\n        local splitHW = 4\n        local mx2h, my2h = state.mouseX or -1, state.mouseY or -1\n        local splitHover = (mx2h >= splitHX and mx2h < splitHX + splitHW\n            and my2h >= lnY and my2h < lnY + lnH)\n            or (state._lnSplitDragActive == true)\n        gfx.setColor(splitHover and 80 or 160, splitHover and 120 or 175, splitHover and 200 or 215, 255)\n        gfx.fillRect(splitHX, lnY, splitHW, lnH)\n        -- Grip dots\n        gfx.setColor(splitHover and 220 or 200, splitHover and 230 or 210, splitHover and 255 or 240, 200)\n        local gripMidY = lnY + math.floor(lnH / 2)\n        for gi = -1, 1 do\n            gfx.fillRect(splitHX + 1, gripMidY + gi * 4, 2, 2)\n        end\n        -- Store handle bounds; totalW = lnSideW + gap(4) + textbox width\n        state._lnSplitHandle = {x = splitHX, y = lnY, w = splitHW, h = lnH,\n            totalW = lnW + 4 + boxW, originX = lnX}\n    end\n\n    -- Build word-wrapped display lines\n    gfx.setFont(textFontStyle)\n    local textW = boxW - SOLFEGE_BOX_PAD * 2 - SOLFEGE_TEXT_INSET - 4\n    local lineStartOfs = {}\n    do\n        local lpos = 1\n        for li = 1, lineCount do\n            lineStartOfs[li] = lpos\n            lpos = lpos + #(lines[li] or \"\") + 1\n        end\n    end\n    local dispLines = {}\n    for li = 1, lineCount do\n        local src = lines[li] or \"\"\n        local loBase = lineStartOfs[li]\n        if src == \"\" or gfx.getTextSize(src) <= textW then\n            dispLines[#dispLines + 1] = {text = src, ln = li, first = true, bufOfs = loBase}\n        else\n            local wds, wdOfs = {}, {}\n            local si = 1\n            for w in src:gmatch(\"%S+\") do\n                local found = src:find(w, si, true)\n                wds[#wds + 1] = w\n                wdOfs[#wdOfs + 1] = found - 1\n                si = found + #w\n            end\n            local cur, firstWrap, segFirst = \"\", true, 1\n            for wi, w in ipairs(wds) do\n                local cand = cur == \"\" and w or (cur .. \" \" .. w)\n                if gfx.getTextSize(cand) <= textW then\n                    cur = cand\n                else\n                    dispLines[#dispLines + 1] = {text = cur, ln = li, first = firstWrap, bufOfs = loBase + wdOfs[segFirst]}\n                    firstWrap = false; cur = w; segFirst = wi\n                end\n            end\n            dispLines[#dispLines + 1] = {text = cur, ln = li, first = firstWrap, bufOfs = loBase + (wdOfs[segFirst] or 0)}\n        end\n    end\n    local visLineCount = math.max(1, #dispLines)\n    state._solfegeVisLineCount = visLineCount\n    state._solfegeMaxVisLines = maxVisLines\n\n    local maxSL = math.max(0, visLineCount - maxVisLines)\n    local scrollLine = math.max(0, math.min(state.solfegeScrollLine or 0, maxSL))\n    if active and not state._pendingSolfegeClick then\n        local curBufPos = state.solfegeInputCursor or (#buf + 1)\n        local cursorLine = visLineCount\n        for vi = #dispLines, 1, -1 do\n            if (dispLines[vi].bufOfs or 1) <= curBufPos then cursorLine = vi; break end\n        end\n        if cursorLine > scrollLine + maxVisLines then scrollLine = cursorLine - maxVisLines\n        elseif cursorLine <= scrollLine then scrollLine = math.max(0, cursorLine - 1) end\n    end\n    -- Auto-scroll when drag-select extends above or below the text box (~12 lines/sec)\n    if active and state._solfegeDragSel and state._solfegeDragRawY then\n        local rawY = state._solfegeDragRawY\n        local now = os.clock()\n        local lastST = state._solfegeDragScrollTime or 0\n        if (now - lastST) >= 0.08 then\n            if rawY < boxY and scrollLine > 0 then\n                scrollLine = scrollLine - 1\n                state._solfegeDragScrollTime = now\n            elseif rawY >= boxY + boxH and scrollLine < maxSL then\n                scrollLine = scrollLine + 1\n                state._solfegeDragScrollTime = now\n            end\n        end\n    end\n    if not active and state._solfegeStepHighlightStart then\n        local hlBufPos = state._solfegeStepHighlightStart\n        local hlLine = visLineCount\n        for vi = #dispLines, 1, -1 do\n            if (dispLines[vi].bufOfs or 1) <= hlBufPos then hlLine = vi; break end\n        end\n        if hlLine > scrollLine + maxVisLines then\n            scrollLine = math.min(maxSL, hlLine - maxVisLines)\n        elseif hlLine <= scrollLine then\n            scrollLine = math.max(0, hlLine - 1)\n        end\n    end\n    state.solfegeScrollLine = scrollLine\n\n    -- Drop highlight when a lyric token is being dragged over this box\n    local dragOverInput = false\n    do\n        local ltd = ctx and ctx.lyricTokenDrag\n        if ltd and ltd.active and ltd.moved then\n            local dx, dy = ltd.x or -1, ltd.y or -1\n            if dx >= boxX and dy >= boxY and dx < boxX + boxW and dy < boxY + boxH then\n                dragOverInput = true\n            end\n        end\n    end\n\n    -- White input box (tinted blue when a lyric token hovers over it)\n    if dragOverInput then\n        gfx.setColor(220, 235, 255, 255)\n    else\n        setThemeColor(gfx, state, \"input\")\n    end\n    gfx.fillRect(boxX, boxY, boxW, boxH)\n    if dragOverInput then\n        gfx.setColor(30, 100, 210, 255)\n    elseif active then\n        setThemeColor(gfx, state, \"inputActive\")\n    else\n        setThemeColor(gfx, state, \"inputIdle\")\n    end\n    gfx.drawRect(boxX, boxY, boxW, boxH)\n    if dragOverInput then\n        -- Extra inner highlight border for clarity\n        gfx.setColor(30, 100, 210, 80)\n        gfx.drawRect(boxX + 1, boxY + 1, boxW - 2, boxH - 2)\n    end\n\n    -- Parse error badge: bottom-right corner inside the white box\n    local _pec = state._parseErrorCount\n    if _pec and _pec > 0 then\n        gfx.setFont(\"small\")\n        local errLabel = \"(!) \" .. _pec .. \" unrecognized\"\n        local ew, eh = gfx.getTextSize(errLabel)\n        local ex = boxX + boxW - ew - 6\n        local ey = boxY + boxH - eh - 4\n        gfx.setColor(180, 40, 40, 220)\n        gfx.fillRoundRect(ex - 3, ey - 1, ew + 6, eh + 2, 3)\n        gfx.setColor(255, 255, 255, 255)\n        gfx.drawText(errLabel, ex, ey)\n        gfx.setFont(\"bold\")\n        gfx.setColor(0, 0, 0, 255)\n    end\n\n    gfx.setFont(textFontStyle)\n    local textX = boxX + SOLFEGE_BOX_PAD + SOLFEGE_TEXT_INSET\n    local firstLineY = boxY + SOLFEGE_BOX_PAD\n    local _, _fontH = gfx.getTextSize(\"|\")\n    local cursorH = (_fontH and _fontH > 0) and _fontH or (SOLFEGE_LINE_H - 2)\n\n    -- Build visible display lines with charXs for click-to-cursor\n    local visibleDls = {}\n    do\n        for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n            local dl = dispLines[vi]\n            local visRow = vi - scrollLine\n            local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n            local lineText = dl.text or \"\"\n            local charXs = {[0] = 0}\n            for ci = 1, #lineText do charXs[ci] = gfx.getTextSize(lineText:sub(1, ci)) end\n            visibleDls[#visibleDls + 1] = {text = lineText, bufOfs = dl.bufOfs or 1, lineY = ly, lineH = SOLFEGE_LINE_H, charXs = charXs}\n        end\n        state._solfegeDispLines = visibleDls\n        state._solfegeTextX = textX\n\n        -- Apply deferred click-to-cursor\n        if state._pendingSolfegeClick then\n            local pc = state._pendingSolfegeClick\n            state._pendingSolfegeClick = nil\n            local px, py = pc.x, pc.y\n            local clickedDl = visibleDls[#visibleDls]\n            for _, dl in ipairs(visibleDls) do\n                if py < dl.lineY + dl.lineH then clickedDl = dl; break end\n            end\n            if clickedDl then\n                local relX = px - textX\n                local lineText = clickedDl.text or \"\"\n                local cxs = clickedDl.charXs or {[0] = 0}\n                local bestOffset = #lineText\n                for ci = 0, #lineText - 1 do\n                    local midX = ((cxs[ci] or 0) + (cxs[ci + 1] or 0)) / 2\n                    if relX <= midX then bestOffset = ci; break end\n                end\n                local newCursor = (clickedDl.bufOfs or 1) + bestOffset\n                local newAbsPos = (newCursor > #buf) and (#buf + 1) or newCursor\n                state.solfegeInputCursor = (newCursor > #buf) and nil or newCursor\n                state._solfegeLastCursorActivity = os.clock()\n                if pc.isTripleClick then\n                    -- Select entire buffer line\n                    local ls = solfegeLineStart(buf, newAbsPos)\n                    local le = solfegeLineEnd(buf, newAbsPos)\n                    state.solfegeSelAnchor = ls\n                    state.solfegeSelFocus  = le\n                    state.solfegeInputCursor = (le > #buf) and nil or le\n                elseif pc.isDoubleClick and pc.isSelExtend then\n                    -- Extend selection to word boundary\n                    local ws = solfegeWordStart(buf, newAbsPos)\n                    local we = solfegeWordEnd(buf, ws)\n                    if not state.solfegeSelAnchor then\n                        state.solfegeSelAnchor = state.solfegeInputCursor or (#buf + 1)\n                    end\n                    if newAbsPos < state.solfegeSelAnchor then\n                        state.solfegeSelFocus = ws\n                    else\n                        state.solfegeSelFocus = we\n                    end\n                    state.solfegeInputCursor = (state.solfegeSelFocus > #buf) and nil or state.solfegeSelFocus\n                elseif pc.isDoubleClick then\n                    local ws = solfegeWordStart(buf, newAbsPos)\n                    local we = solfegeWordEnd(buf, ws)\n                    if we > ws then\n                        state.solfegeSelAnchor = ws\n                        state.solfegeSelFocus  = we\n                        state.solfegeInputCursor = (we > #buf) and nil or we\n                    end\n                elseif pc.isSelExtend then\n                    if not state.solfegeSelAnchor then\n                        -- Anchor at cursor (where mouse-down landed), not at current drag pos\n                        state.solfegeSelAnchor = state.solfegeInputCursor or (#buf + 1)\n                    end\n                    state.solfegeSelFocus = newAbsPos\n                else\n                    state.solfegeSelAnchor = nil\n                    state.solfegeSelFocus  = nil\n                end\n            end\n        end\n    end\n\n    -- Draw text content\n    if buf == \"\" and not active then\n        gfx.setColor(160, 160, 160, 255)\n        local curMode = state.solfegeTextMode or \"both\"\n        if curMode == \"steps\" or curMode == \"both\" then\n            gfx.drawText(\"e.g.  Do  Re4  Mi/2  Fa/d4|word  --\", textX, firstLineY)\n            gfx.setFont(\"small\")\n            gfx.drawText(\"syllable  octave  /duration  |lyric\", textX, firstLineY + SOLFEGE_LINE_H)\n            gfx.setFont(textFontStyle)\n        else\n            gfx.drawText(\"Type lyrics here...\", textX, firstLineY)\n        end\n    else\n        local showBreaks = (state.solfegeShowBreaks ~= false)\n        -- Selection highlight\n        do\n            local selLo, selHi = nil, nil\n            if state.solfegeSelAnchor and state.solfegeSelFocus\n               and state.solfegeSelAnchor ~= state.solfegeSelFocus then\n                local a, f = state.solfegeSelAnchor, state.solfegeSelFocus\n                selLo = math.min(a, f); selHi = math.max(a, f)\n            end\n            local _barBottomY, _barMinX, _barMaxX = nil, nil, nil\n            if selLo then\n                gfx.setColor(100, 160, 230, 120)\n                for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n                    local dl = dispLines[vi]\n                    if dl then\n                        local lineOfs = dl.bufOfs or 1\n                        local lineText = dl.text or \"\"\n                        local lineEnd  = lineOfs + #lineText\n                        if selLo < lineEnd and selHi > lineOfs then\n                            local visRow = vi - scrollLine\n                            local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n                            local vdl = visibleDls[visRow]\n                            local cxs = (vdl and vdl.charXs) or {[0]=0}\n                            local startChar = math.max(0, selLo - lineOfs)\n                            local endChar   = math.min(#lineText, selHi - lineOfs)\n                            local x1 = textX + (cxs[startChar] or gfx.getTextSize(lineText:sub(1, startChar)))\n                            local x2\n                            if selHi > lineEnd then\n                                x2 = boxX + boxW - SOLFEGE_BOX_PAD - 1\n                            else\n                                x2 = textX + (cxs[endChar] or gfx.getTextSize(lineText:sub(1, endChar)))\n                            end\n                            if x2 > x1 then\n                                gfx.fillRect(x1, ly, x2 - x1, cursorH)\n                                _barBottomY = ly + cursorH\n                                if _barMinX == nil or x1 < _barMinX then _barMinX = x1 end\n                                if _barMaxX == nil or x2 > _barMaxX then _barMaxX = x2 end\n                            end\n                        end\n                    end\n                end\n            end\n            drawSolfegeSelBar(gfx, state, _barBottomY, _barMinX, _barMaxX, boxX, boxY, boxW, boxH, textFontStyle)\n        end\n        -- Step token highlight (amber) for current step navigation\n        do\n            local hlS = state._solfegeStepHighlightStart\n            local hlE = state._solfegeStepHighlightEnd\n            if hlS and hlE and not state.solfegeInputActive then\n                gfx.setColor(220, 150, 30, 90)\n                for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n                    local dl = dispLines[vi]\n                    if dl then\n                        local lineOfs = dl.bufOfs or 1\n                        local lineText = dl.text or \"\"\n                        local lineEnd = lineOfs + #lineText\n                        if hlS < lineEnd and (hlE + 1) > lineOfs then\n                            local visRow = vi - scrollLine\n                            local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n                            local vdl = visibleDls[visRow]\n                            local cxs = (vdl and vdl.charXs) or {[0]=0}\n                            local startChar = math.max(0, hlS - lineOfs)\n                            local endChar = math.min(#lineText, (hlE + 1) - lineOfs)\n                            local x1 = textX + (cxs[startChar] or gfx.getTextSize(lineText:sub(1, startChar)))\n                            local x2 = textX + (cxs[endChar] or gfx.getTextSize(lineText:sub(1, endChar)))\n                            if x2 > x1 then\n                                gfx.fillRect(x1, ly, x2 - x1, cursorH)\n                            end\n                        end\n                    end\n                end\n            end\n        end\n        for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n            local visRow = vi - scrollLine\n            local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n            local dl = dispLines[vi]\n            if showBreaks and dl and dl.first and dl.ln > 1 then\n                gfx.setColor(180, 180, 180, 255)\n                gfx.drawText(tostring(dl.ln), boxX - SOLFEGE_PAD + 1, ly)\n            end\n            drawSolfegeLineText(gfx, dl and dl.text or \"\", textX, ly)\n            if showBreaks and dl and dl.text and dl.text ~= \"\" then\n                local nextDl = dispLines[vi + 1]\n                if nextDl and nextDl.ln ~= dl.ln then\n                    local tw = gfx.getTextSize(dl.text)\n                    local markerX = textX + tw + 3\n                    gfx.setColor(190, 190, 190, 200)\n                    if nextDl.text == \"\" then\n                        gfx.drawText(\"\\182\", markerX, ly)\n                    else\n                        gfx.drawText(\"\\226\\134\\181\", markerX, ly)\n                    end\n                end\n            end\n            -- Spell check underlines (wavy/zigzag)\n            if state._solfegeSpellErrors and dl then\n                local lineText = dl.text or \"\"\n                local lineOfs = dl.bufOfs or 1\n                for _, err in ipairs(state._solfegeSpellErrors) do\n                    local rs = err.bufStart - lineOfs + 1\n                    local re = err.bufEnd - lineOfs + 1\n                    if re >= 1 and rs <= #lineText then\n                        local cs = math.max(1, rs)\n                        local ce = math.min(#lineText, re)\n                        local ex1 = textX + (cs > 1 and gfx.getTextSize(lineText:sub(1, cs-1)) or 0)\n                        local ex2 = textX + gfx.getTextSize(lineText:sub(1, ce))\n                        gfx.setColor(220, 60, 60, 240)\n                        local baseY = ly + SOLFEGE_LINE_H - 2\n                        local wstep = 3\n                        local wamp  = 1\n                        local wx = ex1\n                        while wx + wstep <= ex2 do\n                            local seg = math.floor((wx - ex1) / wstep) % 2\n                            local wy1 = baseY + (seg == 0 and -wamp or wamp)\n                            local wy2 = baseY + (seg == 0 and wamp or -wamp)\n                            gfx.drawLine(wx, wy1, wx + wstep, wy2)\n                            wx = wx + wstep\n                        end\n                        if wx < ex2 then\n                            gfx.drawLine(wx, baseY, ex2, baseY)\n                        end\n                    end\n                end\n            end\n        end\n        -- Cursor: always visible while dragging\n        local _tdDrag2 = state._solfegeTextDragMove\n        local _tdActive2 = _tdDrag2 and _tdDrag2.active and _tdDrag2.moved\n        if active and not _tdActive2 then\n            local sinceAct = os.clock() - (state._solfegeLastCursorActivity or 0)\n            local blinkOn = state._solfegeDragSel or sinceAct < 0.5 or (math.floor(os.clock() * 2) % 2 == 0)\n            if blinkOn then\n                local curBufPos2 = state.solfegeInputCursor or (#buf + 1)\n                local cursorLine = visLineCount\n                for vi = #dispLines, 1, -1 do\n                    if (dispLines[vi].bufOfs or 1) <= curBufPos2 then cursorLine = vi; break end\n                end\n                if cursorLine > scrollLine and cursorLine <= scrollLine + maxVisLines then\n                    local dl2 = dispLines[cursorLine]\n                    local lineText2 = dl2 and dl2.text or \"\"\n                    local charsIn = math.max(0, math.min(curBufPos2 - (dl2 and dl2.bufOfs or 1), #lineText2))\n                    local prefix2 = lineText2:sub(1, charsIn)\n                    local tw = (prefix2 ~= \"\") and gfx.getTextSize(prefix2) or 0\n                    local cursorX = math.max(textX, math.min(boxX + boxW - SOLFEGE_BOX_PAD - 4, textX + tw))\n                    local lineTop = firstLineY + (cursorLine - scrollLine - 1) * SOLFEGE_LINE_H\n                    local cursorY = lineTop\n                    state._solfegeCursorScreenX = cursorX\n                    state._solfegeCursorScreenY = cursorY + cursorH\n                    gfx.setColor(0, 0, 0, 255)\n                    gfx.fillRect(cursorX, cursorY, _touchMode and 2 or 1, cursorH)\n                end\n            end\n        end\n        -- Drop cursor: shown when dragging text to a new position\n        if _tdActive2 and _tdDrag2.dropPos then\n            local dropBufPos = _tdDrag2.dropPos\n            local dropLine = visLineCount\n            for vi = #dispLines, 1, -1 do\n                if (dispLines[vi].bufOfs or 1) <= dropBufPos then dropLine = vi; break end\n            end\n            if dropLine > scrollLine and dropLine <= scrollLine + maxVisLines then\n                local ddl = dispLines[dropLine]\n                local dLineText = ddl and ddl.text or \"\"\n                local dCharsIn = math.max(0, math.min(dropBufPos - (ddl and ddl.bufOfs or 1), #dLineText))\n                local dPrefix = dLineText:sub(1, dCharsIn)\n                local dtw = (dPrefix ~= \"\") and gfx.getTextSize(dPrefix) or 0\n                local dropX = math.max(textX, math.min(boxX + boxW - SOLFEGE_BOX_PAD - 4, textX + dtw))\n                local dropLineTop = firstLineY + (dropLine - scrollLine - 1) * SOLFEGE_LINE_H\n                gfx.setColor(220, 120, 30, 255)\n                gfx.drawLine(dropX, dropLineTop, dropX, dropLineTop + cursorH - 1)\n                gfx.drawLine(dropX + 1, dropLineTop, dropX + 1, dropLineTop + cursorH - 1)\n            end\n        end\n    end\n\n    -- Scrollbar\n    if visLineCount > maxVisLines then\n        local sbX2 = boxX + boxW - 3\n        local sbH2 = math.max(1, boxH - SOLFEGE_BOX_PAD * 2)\n        local thumbH = math.max(4, math.floor(sbH2 * maxVisLines / visLineCount))\n        local thumbY = boxY + SOLFEGE_BOX_PAD + (maxSL > 0 and math.floor((sbH2 - thumbH) * scrollLine / maxSL) or 0)\n        gfx.setColor(210, 210, 210, 220)\n        gfx.fillRect(sbX2, boxY + SOLFEGE_BOX_PAD, 3, sbH2)\n        gfx.setColor(120, 120, 120, 220)\n        gfx.fillRect(sbX2, thumbY, 3, thumbH)\n    end\n\n    drawSolfegeAutocomplete(gfx, state)\n    drawSolfegeTemplatePicker(gfx, state)\n    drawSolfegeSizeMenu(gfx, state)\n    drawSolfegeTemplateMenu(gfx, state)\n    gfx.setColor(0, 0, 0, 255)\n    state._solfegeInputBounds = {x = boxX, y = boxY, w = boxW, h = boxH}\n    else\n        state._solfegeInputBounds = nil\n        state._solfegePanelBounds = nil\n        state._solfegeModeBtns = nil\n        state._solfegelyricNotesPanelBtn = nil\n        state._solfegeLyricsWindowBtn = nil\n        state._solfegeBreakToggleBtn = nil\n        state._solfegeSpellBtn = nil\n        state._solfegeTemplateBtn = nil\n        state._solfegeTemplateMenuItems = nil\n        state._solfegeKeyMinusBtn = nil\n        state._solfegeKeyPlusBtn = nil\n        state._solfegeOctMinusBtn = nil\n        state._solfegeOctPlusBtn = nil\n        state._solfegeTextOnlyBtn = nil\n        state._solfegeOctToggleBtn = nil\n        state._solfegeParagraphBtn = nil\n        state._solfegeDragHandle = nil\n        state._solfegeBottomResizeHandle = nil\n        state._solfegeDockBtns = nil\n        state._solfegeSizeBtn = nil\n        state._solfegeSizeMenuItems = nil\n        state._solfegeCopyBtn = nil\n        state._solfegeCutBtn = nil\n        state._solfegePasteBtn = nil\n    end\n\n    -- MIDI Learn banner (quick-learn from main screen via 'm' key)\n    if state.midiLearnMode and state.midiLearnTarget == \"play_stop\"\n       and not state.showingMidiControls then\n        local msg = \"Press your play button now  (Esc to cancel)\"\n        gfx.setFont(\"normal\")\n        local tw, th = gfx.getTextSize(msg)\n        local bx = math.floor((SCREEN_W - tw - 14) / 2)\n        local by = math.floor(SCREEN_H / 2) - 12\n        gfx.setColor(20, 80, 200, 220)\n        gfx.fillRoundRect(bx, by, tw + 14, th + 8, 5)\n        gfx.setColor(255, 255, 255, 255)\n        gfx.drawText(msg, bx + 7, by + 4)\n        gfx.setColor(0, 0, 0, 255)\n        gfx.setFont(\"bold\")\n    end\n\n    -- Context menu overlay (right-click on text box)\n    drawSolfegeCtxMenu(gfx, state)\n\n    -- Lyric notes panel in main window when text box is in its own OS window\n    if (state.solfegeTextInputSide or \"bottom\") == \"window\"\n       and state.lyricNotesPanelOpen and not state.lyricNotesDetached\n       and not ctx.showWelcomeScreen\n       and not state.showingModeSelect\n       and not state.showingTemplateBrowser then\n        local lyricNotesH = 72\n        local lnPad = 4\n        local lnX = lnPad\n        local lnW = SCREEN_W - lnPad * 2\n        local lnY = SCREEN_H - lyricNotesH - lnPad\n        local lnH = lyricNotesH\n        local lnLineH = 14\n        local lnInputY = lnY + 18\n        local lnInputW = lnW - lnPad * 2\n        local lnMaxLines = 2\n        local lnBlink = (math.floor(os.clock() * 2) % 2 == 0)\n        gfx.setColor(255, 255, 255, 255)\n        gfx.fillRect(lnX, lnY, lnW, lnH)\n        gfx.setColor(150, 165, 210, 255)\n        gfx.drawRect(lnX, lnY, lnW, lnH)\n        gfx.setColor(170, 180, 215, 255)\n        gfx.drawLine(lnX + 1, lnY + lnH - 1, lnX + lnW - 2, lnY + lnH - 1)\n        gfx.setFont(\"normal\")\n        gfx.setColor(50, 65, 110, 255)\n        gfx.drawText(\"Lyric Notes\", lnX + lnPad, lnY + 3)\n        local lnBtnGap = 3\n        local lnDetachW, lnDetachH = 18, 12\n        local lnDetachX = lnX + lnW - lnPad - lnDetachW - lnBtnGap - 14\n        local lnDetachY = lnY + 3\n        gfx.setColor(200, 200, 210, 230)\n        gfx.fillRect(lnDetachX, lnDetachY, lnDetachW, lnDetachH)\n        gfx.setColor(130, 130, 150, 255)\n        gfx.drawRect(lnDetachX, lnDetachY, lnDetachW, lnDetachH)\n        gfx.setColor(80, 80, 100, 255)\n        local lnDtw = gfx.getTextSize(\"⊡\")\n        gfx.drawText(\"⊡\", lnDetachX + math.floor((lnDetachW - lnDtw) / 2), lnDetachY + 1)\n        state._lyricNotesDetachBtn = {x = lnDetachX, y = lnDetachY, w = lnDetachW, h = lnDetachH}\n        local lnCloseW, lnCloseH = 14, 12\n        local lnCloseX = lnX + lnW - lnCloseW - lnPad\n        local lnCloseY = lnY + 3\n        gfx.setColor(200, 200, 210, 230)\n        gfx.fillRect(lnCloseX, lnCloseY, lnCloseW, lnCloseH)\n        gfx.setColor(130, 130, 150, 255)\n        gfx.drawRect(lnCloseX, lnCloseY, lnCloseW, lnCloseH)\n        gfx.setColor(80, 80, 100, 255)\n        local lnCxw = gfx.getTextSize(\"×\")\n        gfx.drawText(\"×\", lnCloseX + math.floor((lnCloseW - lnCxw) / 2), lnCloseY + 1)\n        state._lyricNotesCloseBtn = {x = lnCloseX, y = lnCloseY, w = lnCloseW, h = lnCloseH}\n        local lnBuf = state.lyricNotesBuffer or \"\"\n        local lnCursorPos = state.lyricNotesCursor\n        local lnLines = {\"\"}\n        local lnLineBufStarts = {1}\n        for lnWs, lnWord in lnBuf:gmatch(\"()(%S+)\") do\n            local lnCand = lnLines[#lnLines] == \"\" and lnWord or (lnLines[#lnLines] .. \" \" .. lnWord)\n            if gfx.getTextSize(lnCand) <= lnInputW then\n                lnLines[#lnLines] = lnCand\n            else\n                table.insert(lnLines, lnWord)\n                lnLineBufStarts[#lnLines] = lnWs\n            end\n        end\n        if lnBuf:match(\"%s$\") and lnLines[#lnLines] ~= \"\" then\n            table.insert(lnLines, \"\")\n            lnLineBufStarts[#lnLines] = #lnBuf + 1\n        end\n        local lnCursorLine = #lnLines\n        local lnCharInLine = #lnLines[#lnLines]\n        if lnCursorPos ~= nil then\n            for i = 1, #lnLines - 1 do\n                if lnCursorPos < lnLineBufStarts[i + 1] - 1 then lnCursorLine = i; break end\n            end\n            lnCharInLine = math.max(0, math.min(lnCursorPos - (lnLineBufStarts[lnCursorLine] - 1), #lnLines[lnCursorLine]))\n        end\n        local lnStart = math.max(1, lnCursorLine - lnMaxLines + 1)\n        local lnLineMap = {}\n        for i = lnStart, #lnLines do\n            lnLineMap[i - lnStart + 1] = {bufStart = (lnLineBufStarts[i] or 1) - 1, text = lnLines[i]}\n        end\n        state._lyricNotesInputLineMap = lnLineMap\n        state._lyricNotesInputX = lnX + lnPad\n        state._lyricNotesInputLineH = lnLineH\n        state._lyricNotesInputY = lnInputY\n        -- Selection highlight\n        do\n            local lnSelLo, lnSelHi = nil, nil\n            local lnSA, lnSF = state.lyricNotesSelAnchor, state.lyricNotesSelFocus\n            if lnSA ~= nil and lnSF ~= nil and lnSA ~= lnSF then\n                lnSelLo = math.min(lnSA, lnSF); lnSelHi = math.max(lnSA, lnSF)\n            end\n            if lnSelLo then\n                local _, lnFontH = gfx.getTextSize(\"|\")\n                local lnSelH = (lnFontH and lnFontH > 0) and lnFontH or (lnLineH - 1)\n                gfx.setColor(100, 160, 230, 120)\n                for i = lnStart, math.min(lnStart + lnMaxLines - 1, #lnLines) do\n                    local bufStart = (lnLineBufStarts[i] or 1) - 1\n                    local lineText = lnLines[i]\n                    local lineEnd = bufStart + #lineText\n                    if lnSelLo < lineEnd and lnSelHi > bufStart then\n                        local sc = math.max(0, lnSelLo - bufStart)\n                        local ec = math.min(#lineText, lnSelHi - bufStart)\n                        local x1 = lnX + lnPad + gfx.getTextSize(lineText:sub(1, sc))\n                        local x2 = lnX + lnPad + gfx.getTextSize(lineText:sub(1, ec))\n                        if x2 > x1 then\n                            gfx.fillRect(x1, lnInputY + (i - lnStart) * lnLineH, x2 - x1, lnSelH)\n                        end\n                    end\n                end\n            end\n        end\n        gfx.setColor(20, 30, 60, 255)\n        for i = lnStart, math.min(lnStart + lnMaxLines - 1, #lnLines) do\n            if lnLines[i] ~= \"\" then\n                gfx.drawText(lnLines[i], lnX + lnPad, lnInputY + (i - lnStart) * lnLineH)\n            end\n        end\n        local lnCursorVisible = state.lyricNotesInputActive\n            and (lnCursorPos ~= nil or lnBlink)  -- solid mid-text, blinking at end\n            and lnCursorLine >= lnStart and lnCursorLine <= lnStart + lnMaxLines - 1\n        if lnCursorVisible then\n            local _, lnFontH2 = gfx.getTextSize(\"|\")\n            local lnCurH = (lnFontH2 and lnFontH2 > 0) and lnFontH2 or (lnLineH - 1)\n            local lnCursorX = lnX + lnPad + (lnCharInLine > 0 and gfx.getTextSize(lnLines[lnCursorLine]:sub(1, lnCharInLine)) or 0)\n            local lnCursorY = lnInputY + (lnCursorLine - lnStart) * lnLineH\n            gfx.setColor(30, 30, 80, 255)\n            gfx.fillRect(lnCursorX, lnCursorY, _touchMode and 2 or 1, lnCurH)\n        end\n        state._lyricNotesPanelBounds = {\n            x = lnX, y = lnY, width = lnW, height = lnH,\n            inputX = lnX + lnPad, inputY = lnInputY,\n            inputW = lnInputW, inputH = lnMaxLines * lnLineH,\n        }\n        state._lyricNotesTokenBounds = {}\n    end\n\n    -- ===== DEFERRED TOP-MENU DROPDOWNS =====\n    -- Rendered here (after the solfege text panel) so they appear on top even in\n    -- lyrics-only mode where the solfege gray area fills most of the screen.\n\n    -- Edit dropdown\n    if state.editDropdownOpen and state._editDropdownAnchorX then\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local menuW = 90\n        local menuH = #EDIT_DROPDOWN_ITEMS * itemH + (DROPDOWN_VERTICAL_PADDING * 2)\n        local menuX = state._editDropdownAnchorX\n        local menuY = state._editDropdownAnchorY or 56\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local btns = {}\n        gfx.setFont(\"normal\")\n        for i, item in ipairs(EDIT_DROPDOWN_ITEMS) do\n            local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n            local isToggle = item.key == \"spell_check\"\n            local isActive = isToggle and (state.solfegeSpellCheck == true)\n            if isActive then\n                gfx.fillRoundRect(menuX + 2, itemY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            local marker = isToggle and (isActive and \"\\226\\156\\147 \" or \"  \") or \"\"\n            gfx.drawText(marker .. item.label, menuX + 6, itemY + getDropdownTextYOffset(gfx, itemH))\n            gfx.setDrawMode(\"copy\")\n            btns[i] = {x = menuX, y = itemY, w = menuW, h = itemH, key = item.key}\n        end\n        gfx.setFont(\"bold\")\n        state._editDropdownBtns = btns\n    end\n\n    -- File dropdown with submenus\n    if state.fileDropdownOpen and state._fileDropdownAnchorX then\n        local itemH   = DROPDOWN_ITEM_HEIGHT\n        local menuW   = 116\n        local subMenuW = 148\n        local recentFiles = state.fileDropdownRecentFiles or {}\n        local menuH   = DROPDOWN_VERTICAL_PADDING * 2 + #FILE_DROPDOWN_ITEMS * itemH\n        local menuX   = state._fileDropdownAnchorX\n        local menuY   = state._fileDropdownAnchorY or 26\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local parentBtns = {}\n        local curY = menuY + DROPDOWN_VERTICAL_PADDING\n        gfx.setFont(\"normal\")\n        for _, item in ipairs(FILE_DROPDOWN_ITEMS) do\n            local isActiveSub = item.submenu and state.fileActiveSubmenu == item.submenu\n            if isActiveSub then\n                gfx.fillRoundRect(menuX + 2, curY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            gfx.drawText(item.label, menuX + 6, curY + getDropdownTextYOffset(gfx, itemH))\n            if item.submenu then\n                gfx.drawText(\"▶\", menuX + menuW - 12, curY + getDropdownTextYOffset(gfx, itemH))\n            end\n            if isActiveSub then gfx.setDrawMode(\"copy\") end\n            parentBtns[#parentBtns + 1] = {x = menuX, y = curY, w = menuW, h = itemH, key = item.key, submenu = item.submenu}\n            curY = curY + itemH\n        end\n        gfx.setFont(\"bold\")\n        state._fileDropdownBtns = parentBtns\n        if state.fileActiveSubmenu and state._fileSubmenuAnchorY then\n            local subItems = FILE_SUBMENUS[state.fileActiveSubmenu]\n            local isRecent = state.fileActiveSubmenu == \"open_recent\"\n            local isRevert = state.fileActiveSubmenu == \"revert\"\n            if state.fileActiveSubmenu == \"import\" and subItems then\n                local items = {}\n                for _, v in ipairs(subItems) do items[#items+1] = v end\n                local linked = tostring(state.linkedLyricsDocxPath or \"\")\n                if linked ~= \"\" then\n                    local shortName = linked:match(\"([^/]+)$\") or linked\n                    if #shortName > 18 then shortName = shortName:sub(1, 16) .. \"…\" end\n                    items[#items+1] = { key = \"reimport_linked_docx\", label = \"↩ \" .. shortName }\n                    items[#items+1] = { key = \"unlink_docx\", label = \"Unlink DOCX\" }\n                end\n                subItems = items\n            end\n            local backupFiles = isRevert and (state.fileDropdownBackupFiles or {}) or nil\n            local REVERT_VISIBLE = 5\n            local revertScroll = (isRevert and state._revertSubmenuScroll) or 0\n            local subCount\n            if isRecent then\n                subCount = math.max(1, #recentFiles)\n            elseif isRevert then\n                if #backupFiles == 0 then\n                    subCount = 1\n                else\n                    local visItems = math.min(REVERT_VISIBLE, #backupFiles - revertScroll)\n                    local hasUp = revertScroll > 0\n                    local hasDown = revertScroll + REVERT_VISIBLE < #backupFiles\n                    subCount = visItems + (hasUp and 1 or 0) + (hasDown and 1 or 0)\n                end\n            else\n                subCount = subItems and #subItems or 0\n            end\n            local subH  = subCount * itemH + DROPDOWN_VERTICAL_PADDING * 2\n            local subX  = menuX + menuW + 2\n            local subY  = state._fileSubmenuAnchorY\n            if subX + subMenuW > SCREEN_W - 2 then subX = menuX - subMenuW - 2 end\n            if subY + subH > SCREEN_H - 2 then subY = SCREEN_H - subH - 2 end\n            gfx.setDrawMode(\"fillWhite\")\n            gfx.fillRoundRect(subX, subY, subMenuW, subH, 4)\n            gfx.setDrawMode(\"copy\")\n            gfx.drawRoundRect(subX, subY, subMenuW, subH, 4)\n            local subBtns = {}\n            local sy = subY + DROPDOWN_VERTICAL_PADDING\n            gfx.setFont(\"normal\")\n            if isRecent then\n                if #recentFiles == 0 then\n                    gfx.drawText(\"No recent files\", subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                else\n                    for _, f in ipairs(recentFiles) do\n                        local name = f.name\n                        if #name > 20 then name = name:sub(1, 18) .. \"…\" end\n                        gfx.drawText(name, subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                        subBtns[#subBtns + 1] = {x = subX, y = sy, w = subMenuW, h = itemH, key = \"open_recent\", path = f.path}\n                        sy = sy + itemH\n                    end\n                end\n            elseif isRevert then\n                if #backupFiles == 0 then\n                    gfx.drawText(\"No backups yet\", subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                else\n                    local hasUp = revertScroll > 0\n                    local hasDown = revertScroll + REVERT_VISIBLE < #backupFiles\n                    if hasUp then\n                        gfx.drawText(\"▲ earlier\", subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                        subBtns[#subBtns + 1] = {x = subX, y = sy, w = subMenuW, h = itemH, key = \"revert_scroll_up\"}\n                        sy = sy + itemH\n                    end\n                    for i = revertScroll + 1, math.min(revertScroll + REVERT_VISIBLE, #backupFiles) do\n                        local f = backupFiles[i]\n                        local label = f.label or \"\"\n                        if #label > 20 then label = label:sub(1, 18) .. \"…\" end\n                        gfx.drawText(label, subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                        subBtns[#subBtns + 1] = {x = subX, y = sy, w = subMenuW, h = itemH, key = \"revert\", path = f.path}\n                        sy = sy + itemH\n                    end\n                    if hasDown then\n                        gfx.drawText(\"▼ more\", subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                        subBtns[#subBtns + 1] = {x = subX, y = sy, w = subMenuW, h = itemH, key = \"revert_scroll_down\"}\n                    end\n                end\n            elseif subItems then\n                for _, sub in ipairs(subItems) do\n                    gfx.drawText(sub.label, subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                    subBtns[#subBtns + 1] = {x = subX, y = sy, w = subMenuW, h = itemH, key = sub.key}\n                    sy = sy + itemH\n                end\n            end\n            gfx.setFont(\"bold\")\n            state._fileSubmenuBtns = subBtns\n        else\n            state._fileSubmenuBtns = nil\n        end\n    end\n\n    -- Path hierarchy popup (folder icon click)\n    if state.pathPopupOpen and ctx.musicXMLFilePath and ctx.musicXMLFilePath ~= \"\" then\n        local filePath = tostring(ctx.musicXMLFilePath)\n        local components = {}\n        local isAbsolute = filePath:sub(1, 1) == \"/\"\n        if isAbsolute then components[#components + 1] = {label = \"/\", path = \"/\", isFile = false} end\n        for part in filePath:gmatch(\"[^/]+\") do\n            local prevPath = (components[#components] and components[#components].path) or \"\"\n            if prevPath == \"/\" then prevPath = \"\" end\n            components[#components + 1] = {label = part, path = prevPath .. \"/\" .. part, isFile = false}\n        end\n        if #components > 0 then components[#components].isFile = true end\n        local reversed = {}\n        for i = #components, 1, -1 do reversed[#reversed + 1] = components[i] end\n        components = reversed\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local maxLabelW = 0\n        gfx.setFont(\"normal\")\n        for _, comp in ipairs(components) do\n            local lw = gfx.getTextSize(comp.label)\n            if lw > maxLabelW then maxLabelW = lw end\n        end\n        local menuW = math.max(160, maxLabelW + 16)\n        local menuH = #components * itemH + DROPDOWN_VERTICAL_PADDING * 2\n        local menuX = state._folderIconX or 2\n        local menuY = (state._folderIconY or 0) + (state._folderIconH or 12) + 2\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local btns = {}\n        for i, comp in ipairs(components) do\n            local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n            gfx.drawText(comp.label, menuX + 6, itemY + getDropdownTextYOffset(gfx, itemH))\n            btns[#btns + 1] = {x = menuX, y = itemY, w = menuW, h = itemH, path = comp.path, isFile = comp.isFile}\n        end\n        gfx.setFont(\"bold\")\n        state._pathPopupBtns = btns\n    end\n\n    -- Display options dropdown with submenus (Panels / Notation / Lyrics)\n    if state.displayOptionsDropdownOpen and state._displayOptionsDropdownAnchorX then\n        local itemH    = DROPDOWN_ITEM_HEIGHT\n        local menuW    = 90\n        local subMenuW = 120\n        local menuH    = #DISPLAY_OPTIONS_DROPDOWN_ITEMS * itemH + DROPDOWN_VERTICAL_PADDING * 2\n        local menuX    = state._displayOptionsDropdownAnchorX\n        local menuY    = state._displayOptionsDropdownAnchorY or 106\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local parentBtns = {}\n        local curY = menuY + DROPDOWN_VERTICAL_PADDING\n        gfx.setFont(\"normal\")\n        for _, item in ipairs(DISPLAY_OPTIONS_DROPDOWN_ITEMS) do\n            local isActiveSub = state.viewActiveSubmenu == item.submenu\n            if isActiveSub then\n                gfx.fillRoundRect(menuX + 2, curY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            gfx.drawText(item.label, menuX + 6, curY + getDropdownTextYOffset(gfx, itemH))\n            gfx.drawText(\"▶\", menuX + menuW - 12, curY + getDropdownTextYOffset(gfx, itemH))\n            if isActiveSub then gfx.setDrawMode(\"copy\") end\n            parentBtns[#parentBtns + 1] = {x = menuX, y = curY, w = menuW, h = itemH, key = item.key, submenu = item.submenu}\n            curY = curY + itemH\n        end\n        gfx.setFont(\"bold\")\n        state._displayOptionsDropdownBtns = parentBtns\n        if state.viewActiveSubmenu and state._viewSubmenuAnchorY then\n            local subItems = DISPLAY_SUBMENUS[state.viewActiveSubmenu]\n            local subCount = subItems and #subItems or 0\n            local subH  = subCount * itemH + DROPDOWN_VERTICAL_PADDING * 2\n            local subX  = menuX + menuW + 2\n            local subY  = state._viewSubmenuAnchorY\n            if subX + subMenuW > SCREEN_W - 2 then subX = menuX - subMenuW - 2 end\n            if subY + subH > SCREEN_H - 2 then subY = SCREEN_H - subH - 2 end\n            gfx.setDrawMode(\"fillWhite\")\n            gfx.fillRoundRect(subX, subY, subMenuW, subH, 4)\n            gfx.setDrawMode(\"copy\")\n            gfx.drawRoundRect(subX, subY, subMenuW, subH, 4)\n            local subBtns = {}\n            local sy = subY + DROPDOWN_VERTICAL_PADDING\n            gfx.setFont(\"normal\")\n            if subItems then\n                for _, sub in ipairs(subItems) do\n                    local isActive = false\n                    if sub.key == \"hideSteps\" then\n                        isActive = not state.hideSteps\n                    elseif sub.key == \"composeMode\" then\n                        isActive = state.showSolfegeTextInput ~= true and state.singSolfegeMode ~= true and (state.solfegeTextMode or \"both\") == \"both\"\n                    elseif sub.key == \"singMode\" then\n                        isActive = state.showSolfegeTextInput ~= true and state.singSolfegeMode == true and (state.solfegeTextMode or \"both\") == \"both\"\n                    elseif sub.key == \"lyricsOnlyMode\" then\n                        isActive = state.showSolfegeTextInput == true and state.solfegeTextMode == \"lyrics\" and state.hideSteps == true\n                    elseif sub.key == \"stepsOnlyMode\" then\n                        isActive = state.showSolfegeTextInput == true and state.solfegeTextMode == \"steps\" and state.hideSteps ~= true\n                    elseif sub.key == \"stepsLyricsMode\" then\n                        isActive = state.showSolfegeTextInput == true and state.solfegeTextMode == \"both\" and state.hideSteps ~= true\n                    elseif sub.key == \"lyricsWindow\" then\n                        isActive = (state.solfegeTextInputSide or \"bottom\") == \"window\"\n                    elseif sub.key == \"solfegeShowBreaks\" then\n                        isActive = (state.solfegeShowBreaks ~= false)\n                    else\n                        isActive = state[sub.key]\n                    end\n                    if isActive then\n                        gfx.fillRoundRect(subX + 2, sy, subMenuW - 4, itemH, 2)\n                        gfx.setDrawMode(\"fillWhite\")\n                    end\n                    local marker = isActive and \"✓ \" or \"  \"\n                    gfx.drawText(marker .. sub.label, subX + 6, sy + getDropdownTextYOffset(gfx, itemH))\n                    gfx.setDrawMode(\"copy\")\n                    subBtns[#subBtns + 1] = {x = subX, y = sy, w = subMenuW, h = itemH, key = sub.key}\n                    sy = sy + itemH\n                end\n            end\n            gfx.setFont(\"bold\")\n            state._viewSubmenuBtns = subBtns\n        else\n            state._viewSubmenuBtns = nil\n        end\n    end\n\n    -- Input sources dropdown\n    if state.inputSourcesDropdownOpen and state._inputSourcesDropdownAnchorX then\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local menuW = 108\n        local menuH = #INPUT_SOURCES_DROPDOWN_ITEMS * itemH + (DROPDOWN_VERTICAL_PADDING * 2)\n        local menuX = state._inputSourcesDropdownAnchorX\n        local menuY = state._inputSourcesDropdownAnchorY or 106\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local btns = {}\n        gfx.setFont(\"normal\")\n        for i, item in ipairs(INPUT_SOURCES_DROPDOWN_ITEMS) do\n            local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n            local isActive = (item.key == \"midi_in\" and state.midiInDeviceName and state.midiInDeviceName ~= \"\")\n                or (item.key == \"mic_input\" and state.micInputDeviceName and state.micInputDeviceName ~= \"\")\n                or (item.key == \"gamepad\" and state.gamepadEnabled)\n            if isActive then\n                gfx.fillRoundRect(menuX + 2, itemY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            local marker = isActive and \"✓ \" or \"  \"\n            gfx.drawText(marker .. item.label, menuX + 6, itemY + getDropdownTextYOffset(gfx, itemH))\n            gfx.setDrawMode(\"copy\")\n            btns[i] = {x = menuX, y = itemY, w = menuW, h = itemH, key = item.key}\n        end\n        gfx.setFont(\"bold\")\n        state._inputSourcesDropdownBtns = btns\n    end\n\n    -- Keynote dropdown\n    if state.keynoteDropdownOpen and state._keynoteDropdownAnchorX then\n        local options = getKeynoteOptions(state.solfegeScale)\n        local itemH = DROPDOWN_ITEM_HEIGHT\n        local menuW = 54\n        local menuH = #options * itemH + (DROPDOWN_VERTICAL_PADDING * 2)\n        local menuX = state._keynoteDropdownAnchorX\n        local menuY = state._keynoteDropdownAnchorY or 56\n        if menuX + menuW > SCREEN_W - 2 then menuX = SCREEN_W - menuW - 2 end\n        if menuX < 2 then menuX = 2 end\n        if menuY + menuH > SCREEN_H - 2 then menuY = SCREEN_H - menuH - 2 end\n        gfx.setDrawMode(\"fillWhite\")\n        gfx.fillRoundRect(menuX, menuY, menuW, menuH, 4)\n        gfx.setDrawMode(\"copy\")\n        gfx.drawRoundRect(menuX, menuY, menuW, menuH, 4)\n        local btns = {}\n        gfx.setFont(\"normal\")\n        for i, noteIdx in ipairs(options) do\n            local itemY = menuY + DROPDOWN_VERTICAL_PADDING + (i - 1) * itemH\n            local isActive = (state.rootNote or 0) == noteIdx\n            if isActive then\n                gfx.fillRoundRect(menuX + 2, itemY, menuW - 4, itemH, 2)\n                gfx.setDrawMode(\"fillWhite\")\n            end\n            local marker = isActive and \"✓\" or \" \"\n            local label = (ctx.solfegeNotes[noteIdx + 1] or \"Do\")\n            local fullLabel = marker .. \" \" .. label\n            local lw = gfx.getTextSize(fullLabel)\n            gfx.drawText(fullLabel, menuX + math.floor((menuW - lw) / 2), itemY + getDropdownTextYOffset(gfx, itemH))\n            gfx.setDrawMode(\"copy\")\n            btns[i] = {x = menuX, y = itemY, w = menuW, h = itemH, note = noteIdx}\n        end\n        gfx.setFont(\"bold\")\n        state._keynoteDropdownBtns = btns\n    end\n\n    -- Custom scale steps input dialog\n    if state.scaleStepsInputOpen then\n        local dlgW, dlgH = 200, 82\n        local dlgX = math.floor((SCREEN_W - dlgW) / 2)\n        local dlgY = math.floor((SCREEN_H - dlgH) / 2)\n        local pad = 6\n        gfx.setFont(\"normal\")\n        -- Backdrop\n        gfx.setColor(0, 0, 0, 120)\n        gfx.fillRect(0, 0, SCREEN_W, SCREEN_H)\n        -- Panel\n        gfx.setColor(245, 245, 245, 255)\n        gfx.fillRoundRect(dlgX, dlgY, dlgW, dlgH, 5)\n        gfx.setColor(80, 80, 80, 255)\n        gfx.drawRoundRect(dlgX, dlgY, dlgW, dlgH, 5)\n        gfx.setColor(0, 0, 0, 255)\n        -- Title\n        local titleW = select(1, gfx.getTextSize(\"Custom Scale\"))\n        gfx.drawText(\"Custom Scale\", dlgX + math.floor((dlgW - titleW) / 2), dlgY + pad)\n        -- Hint\n        gfx.setFont(\"small\")\n        local hintW = select(1, gfx.getTextSize(\"intervals e.g. 2 2 1 2 2 2 1\"))\n        gfx.setColor(100, 100, 100, 255)\n        gfx.drawText(\"intervals e.g. 2 2 1 2 2 2 1\", dlgX + math.floor((dlgW - hintW) / 2), dlgY + pad + 16)\n        gfx.setFont(\"normal\")\n        -- Input box\n        local boxX = dlgX + pad\n        local boxY = dlgY + pad + 28\n        local boxW = dlgW - pad * 2\n        local boxH = 18\n        gfx.setColor(255, 255, 255, 255)\n        gfx.fillRect(boxX, boxY, boxW, boxH)\n        gfx.setColor(state.scaleStepsInputError and 180 or 120, state.scaleStepsInputError and 60 or 120, state.scaleStepsInputError and 60 or 120, 255)\n        gfx.drawRect(boxX, boxY, boxW, boxH)\n        local buf = state.scaleStepsInputBuffer or \"\"\n        local blinkOn = (math.floor(os.clock() * 2) % 2 == 0)\n        local displayText = buf .. (blinkOn and \"|\" or \" \")\n        local tw, th = gfx.getTextSize(displayText)\n        gfx.setColor(0, 0, 0, 255)\n        gfx.drawText(displayText, boxX + 3, boxY + math.floor((boxH - th) / 2))\n        -- Error or footer\n        if state.scaleStepsInputError then\n            gfx.setFont(\"small\")\n            gfx.setColor(180, 60, 60, 255)\n            local errW = select(1, gfx.getTextSize(state.scaleStepsInputError))\n            gfx.drawText(state.scaleStepsInputError, dlgX + math.floor((dlgW - errW) / 2), dlgY + pad + 50)\n            gfx.setFont(\"normal\")\n        else\n            gfx.setFont(\"small\")\n            gfx.setColor(100, 100, 100, 255)\n            local footW = select(1, gfx.getTextSize(\"Return: Apply  Esc: Cancel\"))\n            gfx.drawText(\"Return: Apply  Esc: Cancel\", dlgX + math.floor((dlgW - footW) / 2), dlgY + pad + 50)\n            gfx.setFont(\"normal\")\n        end\n        gfx.setColor(0, 0, 0, 255)\n        state._scaleStepsDlgBounds = {x = dlgX, y = dlgY, w = dlgW, h = dlgH}\n    else\n        state._scaleStepsDlgBounds = nil\n    end\n\n    _drawCmdChatPanel(gfx, state)\n\n    -- Keyboard shortcut cheat-sheet overlay (toggled by ?)\n    if state.showShortcutHelp then\n        local shortcuts = {\n            {\"Cmd+Enter\",      \"Play / Pause\"},\n            {\"Arrow keys\",     \"Navigate steps\"},\n            {\"Cmd+Z\",          \"Undo\"},\n            {\"Cmd+Shift+Z\",    \"Redo\"},\n            {\"Cmd+C\",          \"Copy selected steps\"},\n            {\"Cmd+V\",          \"Paste\"},\n            {\"[ / ] buttons\",  \"Set loop start / end\"},\n            {\"Del btn+click\",  \"Delete a step\"},\n            {\"Mute btn+click\", \"Toggle step mute\"},\n            {\"F11\",            \"Toggle fullscreen\"},\n            {\"Esc\",            \"Cancel / deselect\"},\n            {\"?\",              \"Show this help\"},\n        }\n        -- Single-column layout with a fixed key-column width\n        local KEY_W  = 95   -- fixed pixels reserved for the key label\n        local rowH2  = 14\n        local pad2   = 10\n        local dlgW   = math.min(SCREEN_W - 20, 320)\n        local dlgH   = #shortcuts * rowH2 + pad2 * 2 + 20\n        local dlgX   = math.floor((SCREEN_W - dlgW) / 2)\n        -- Stay below the toolbar; centre within remaining vertical space\n        local safeTop = (state._gridStartY or 80)\n        local safeH   = SCREEN_H - safeTop\n        local dlgY    = safeTop + math.max(0, math.floor((safeH - dlgH) / 2))\n\n        gfx.setColor(0, 0, 0, 140)\n        gfx.fillRect(0, 0, SCREEN_W, SCREEN_H)\n\n        gfx.setColor(245, 245, 245, 255)\n        gfx.fillRoundRect(dlgX, dlgY, dlgW, dlgH, 6)\n        gfx.setColor(100, 100, 100, 255)\n        gfx.drawRoundRect(dlgX, dlgY, dlgW, dlgH, 6)\n\n        gfx.setFont(\"bold\")\n        gfx.setColor(0, 0, 0, 255)\n        local titleStr = \"Keyboard Shortcuts\"\n        local tw2 = select(1, gfx.getTextSize(titleStr))\n        gfx.drawText(titleStr, dlgX + math.floor((dlgW - tw2) / 2), dlgY + pad2 - 2)\n\n        gfx.setFont(\"small\")\n        for i, pair in ipairs(shortcuts) do\n            local iy = dlgY + pad2 + 14 + (i - 1) * rowH2\n            -- Key label (blue, right-aligned within key column)\n            gfx.setColor(50, 80, 160, 255)\n            local kw2 = select(1, gfx.getTextSize(pair[1]))\n            gfx.drawText(pair[1], dlgX + pad2 + KEY_W - kw2, iy)\n            -- Separator dash\n            gfx.setColor(160, 160, 160, 255)\n            gfx.drawText(\" - \", dlgX + pad2 + KEY_W, iy)\n            local dashW = select(1, gfx.getTextSize(\" - \"))\n            -- Description (dark, left of description column)\n            gfx.setColor(50, 50, 50, 255)\n            gfx.drawText(pair[2], dlgX + pad2 + KEY_W + dashW, iy)\n        end\n\n        gfx.setFont(\"bold\")\n        gfx.setColor(0, 0, 0, 255)\n        state._shortcutHelpBounds = {x = dlgX, y = dlgY, w = dlgW, h = dlgH}\n    else\n        state._shortcutHelpBounds = nil\n    end\n\n    -- Tooltip overlay\n    if state.tooltip then\n        gfx.setFont(\"small\")\n        local tw, th = gfx.getTextSize(state.tooltip.text)\n        local mx = state.mouseX or 0\n        local tx = math.max(2, math.min(SCREEN_W - tw - 6, mx - math.floor(tw / 2)))\n        local ty = state.tooltip.anchorY\n        setAbsoluteColor(gfx, state, 30, 30, 30, 230)\n        gfx.fillRect(tx - 1, ty, tw + 2, th + 1)\n        setAbsoluteColor(gfx, state, 160, 160, 160, 255)\n        gfx.drawRect(tx - 1, ty, tw + 2, th + 1)\n        setAbsoluteColor(gfx, state, 255, 255, 255, 255)\n        gfx.drawText(state.tooltip.text, tx, ty)\n        gfx.setColor(0, 0, 0, 255)\n        gfx.setFont(\"bold\")\n    end\n\n    -- Solfege syllable buttons row (tappable Do Re Mi ... buttons)\n    -- Drawn last so nothing covers them.\n    if (state.showSolfegeButtons ~= false) and not ctx.showWelcomeScreen\n       and not state.showingModeSelect and not state.showingTemplateBrowser\n       and not state.solfegeTextOnlyMode then\n        local syllRowH = SOLFEGE_SYLLABLE_ROW_H\n        local scaleMode = state.solfegeScale or \"major\"\n        local noteIndices = getKeynoteOptions(scaleMode)\n        local syllableNames = ctx.solfegeNotes or {\"Do\",\"Di\",\"Re\",\"Ri\",\"Mi\",\"Fa\",\"Fi\",\"Sol\",\"Si\",\"La\",\"Li\",\"Ti\",\"Do'\"}\n        local syllRowH2 = syllRowH\n        local rowY = SCREEN_H - syllRowH2 - _safeAreaBottom\n        local rowX = _touchMode and 3 or (isPortraitLayout() and 2 or 4)\n        local rowW = SCREEN_W - rowX * 2\n        local btnCount = #noteIndices\n        local btnGap = _touchMode and math.max(2, math.min(4, math.floor((rowW - btnCount * 36) / math.max(1, btnCount - 1)))) or (isPortraitLayout() and 2 or 4)\n        local btnW = math.floor((rowW - (btnCount - 1) * btnGap) / btnCount)\n        local btnH = syllRowH - (_touchMode and 6 or 6)\n        local totalBtnsW = btnCount * btnW + (btnCount - 1) * btnGap\n        local startX = rowX + math.floor((rowW - totalBtnsW) / 2)\n        local mx, my = state.mouseX or -1, state.mouseY or -1\n        gfx.setFont(\"normal\")\n        local btns = {}\n        local syllR = _touchMode and 8 or 6\n        for i, noteIdx in ipairs(noteIndices) do\n            local bx = startX + (i - 1) * (btnW + btnGap)\n            local by = rowY + (_touchMode and 3 or 2)\n            local label = syllableNames[noteIdx + 1] or (\"?\" .. noteIdx)\n            local hov = mx >= bx and mx < bx + btnW and my >= by and my < by + btnH\n            if hov then\n                setThemeColor(gfx, state, \"buttonHover\")\n            else\n                setThemeColor(gfx, state, \"button\")\n            end\n            gfx.fillRoundRect(bx, by, btnW, btnH, syllR)\n            setThemeColor(gfx, state, \"buttonBorder\")\n            gfx.drawRoundRect(bx, by, btnW, btnH, syllR)\n            setThemeColor(gfx, state, \"text\")\n            local lw, lh = gfx.getTextSize(label)\n            gfx.drawText(label, bx + math.floor((btnW - lw) / 2), by + math.floor((btnH - lh) / 2))\n            btns[#btns + 1] = {x = bx, y = by, w = btnW, h = btnH, noteIndex = noteIdx, label = label}\n        end\n        state._solfegeSyllableBtns = btns\n    end\n\nend\n\nfunction ui.renderLyricNotesWindow(state, gfx, ctx)\n    _gfx = gfx\n    if gfx.getScreenWidth then SCREEN_W = gfx.getScreenWidth() end\n    if gfx.getScreenHeight then SCREEN_H = gfx.getScreenHeight() end\n\n    gfx.setDarkMode(state.darkMode == true)\n    gfx.clear(\"white\")\n\n    local panelX, panelY = 0, 0\n    local panelW, panelH = SCREEN_W, SCREEN_H\n    local innerPad = 6\n    local lineH = 16\n    local cursor = ((math.floor(os.clock() * 2) % 2 == 0) and \"|\" or \" \")\n\n    gfx.setDrawMode(\"fillWhite\")\n    gfx.fillRect(panelX, panelY, panelW, panelH)\n    gfx.setDrawMode(\"copy\")\n    gfx.drawText(\"Lyric Notes\", panelX + innerPad, panelY + 4)\n    do\n        local btnGap = 3\n        local btnH = 14\n        -- Close (X) button\n        local clW = 14\n        local clX = panelX + panelW - innerPad - clW\n        local clY = panelY + 3\n        gfx.fillRect(clX, clY, clW, btnH)\n        gfx.drawRect(clX, clY, clW, btnH)\n        local clTw = gfx.getTextSize(\"X\")\n        gfx.drawText(\"X\", clX + math.floor((clW - clTw) / 2), clY + 2)\n        state._lyricNotesCloseBtn = {x = clX, y = clY, w = clW, h = btnH}\n        -- Un-dock ([]) button\n        local btnW = 18\n        local btnX = clX - btnGap - btnW\n        local btnY = panelY + 3\n        gfx.fillRect(btnX, btnY, btnW, btnH)\n        gfx.drawRect(btnX, btnY, btnW, btnH)\n        local btnTw = gfx.getTextSize(\"[]\")\n        gfx.drawText(\"[]\", btnX + math.floor((btnW - btnTw) / 2), btnY + 2)\n        state._lyricNotesDetachBtn = {x = btnX, y = btnY, w = btnW, h = btnH}\n    end\n\n    local contentY = panelY + 20\n    local maxInputLines = 3\n    local buf = state.lyricNotesBuffer or \"\"\n    local cursorPos = state.lyricNotesCursor\n    local availW = panelW - innerPad * 2\n    local inputLines = {\"\"}\n    local lineBufStarts = {1}\n    for wordStart, word in buf:gmatch(\"()(%S+)\") do\n        local candidate = inputLines[#inputLines] == \"\" and word or (inputLines[#inputLines] .. \" \" .. word)\n        if gfx.getTextSize(candidate) <= availW then\n            inputLines[#inputLines] = candidate\n        else\n            table.insert(inputLines, word)\n            lineBufStarts[#inputLines] = wordStart\n        end\n    end\n    if buf:match(\"%s$\") and inputLines[#inputLines] ~= \"\" then\n        table.insert(inputLines, \"\")\n        lineBufStarts[#inputLines] = #buf + 1\n    end\n    local cursorLine = #inputLines\n    local charInLine = #inputLines[#inputLines]\n    if cursorPos ~= nil then\n        for i = 1, #inputLines - 1 do\n            if cursorPos < lineBufStarts[i + 1] - 1 then\n                cursorLine = i\n                break\n            end\n        end\n        charInLine = math.max(0, math.min(cursorPos - (lineBufStarts[cursorLine] - 1), #inputLines[cursorLine]))\n    end\n    local inputAreaH = maxInputLines * lineH\n    local startLine = math.max(1, cursorLine - maxInputLines + 1)\n    local inputLineMap = {}\n    for i = startLine, #inputLines do\n        local li = i - startLine + 1\n        inputLineMap[li] = {bufStart = (lineBufStarts[i] or 1) - 1, text = inputLines[i]}\n    end\n    state._lyricNotesInputLineMap = inputLineMap\n    state._lyricNotesInputX = panelX + innerPad\n    state._lyricNotesInputLineH = lineH\n    state._lyricNotesInputY = contentY\n    -- Selection highlight\n    do\n        local lnSelLo, lnSelHi = nil, nil\n        local lnSA, lnSF = state.lyricNotesSelAnchor, state.lyricNotesSelFocus\n        if lnSA ~= nil and lnSF ~= nil and lnSA ~= lnSF then\n            lnSelLo = math.min(lnSA, lnSF); lnSelHi = math.max(lnSA, lnSF)\n        end\n        if lnSelLo then\n            local _, lnFontH = gfx.getTextSize(\"|\")\n            local lnSelH = (lnFontH and lnFontH > 0) and lnFontH or (lineH - 1)\n            gfx.setColor(100, 160, 230, 120)\n            for i = startLine, #inputLines do\n                local bufStart = (lineBufStarts[i] or 1) - 1\n                local lineText = inputLines[i]\n                local lineEnd = bufStart + #lineText\n                if lnSelLo < lineEnd and lnSelHi > bufStart then\n                    local sc = math.max(0, lnSelLo - bufStart)\n                    local ec = math.min(#lineText, lnSelHi - bufStart)\n                    local x1 = panelX + innerPad + gfx.getTextSize(lineText:sub(1, sc))\n                    local x2 = panelX + innerPad + gfx.getTextSize(lineText:sub(1, ec))\n                    if x2 > x1 then\n                        gfx.fillRect(x1, contentY + (i - startLine) * lineH, x2 - x1, lnSelH)\n                    end\n                end\n            end\n        end\n    end\n    local cl = inputLines[cursorLine]\n    for i = startLine, #inputLines do\n        if inputLines[i] ~= \"\" then\n            gfx.drawText(inputLines[i], panelX + innerPad, contentY + (i - startLine) * lineH)\n        end\n    end\n    -- Draw vertical line cursor (matching solfege text box style)\n    local blinkOn = (math.floor(os.clock() * 2) % 2 == 0)\n    local lnWinCursorVisible = state.lyricNotesInputActive\n        and (cursorPos ~= nil or blinkOn)  -- solid mid-text, blinking at end\n        and cursorLine >= startLine\n    if lnWinCursorVisible then\n        local _, lnWinFontH = gfx.getTextSize(\"|\")\n        local lnWinCurH = (lnWinFontH and lnWinFontH > 0) and lnWinFontH or (lineH - 1)\n        local lnWinCursorX = panelX + innerPad + (charInLine > 0 and gfx.getTextSize(cl:sub(1, charInLine)) or 0)\n        local lnWinCursorY = contentY + (cursorLine - startLine) * lineH\n        gfx.setColor(30, 30, 80, 255)\n        gfx.drawLine(lnWinCursorX, lnWinCursorY, lnWinCursorX, lnWinCursorY + lnWinCurH - 1)\n    end\n\n    local tokenBounds = {}\n    local tokenizeLyricsText = ctx and ctx.tokenizeLyricsText\n    local tokens = type(tokenizeLyricsText) == \"function\" and tokenizeLyricsText(buf) or {}\n    local tokenY = contentY + inputAreaH + 2\n    local tokenH = 12\n    local tokenGap = 2\n    local tokenX = panelX + innerPad\n    local tokenMaxX = panelX + panelW - innerPad\n    for i, token in ipairs(tokens) do\n        if type(token) == \"string\" and token ~= \"\" then\n            local isEditing = (state.lyricNotesEditingTokenIndex == i)\n            local label = isEditing and (token .. cursor) or token\n            local tw = gfx.getTextSize(label)\n            local chipW = tw + 6\n            if tokenX + chipW > tokenMaxX then\n                tokenX = panelX + innerPad\n                tokenY = tokenY + tokenH + tokenGap\n            end\n            if tokenY + tokenH < panelY + panelH - lineH * 2 then\n                gfx.drawText(label, tokenX + 3, tokenY + 2)\n                table.insert(tokenBounds, {x = tokenX, y = tokenY, w = chipW, h = tokenH, token = token, index = i})\n                tokenX = tokenX + chipW + tokenGap\n            else\n                break\n            end\n        end\n    end\n\n    local helpY = panelY + panelH - lineH * 2 - 2\n    gfx.drawText(\"Enter: Import\", panelX + innerPad, helpY)\n    gfx.drawText(\"Esc: Close\", panelX + innerPad, helpY + lineH)\n    state._lyricNotesPanelBounds = {x = panelX, y = panelY, width = panelW, height = panelH, inputX = panelX + innerPad, inputY = contentY, inputW = availW, inputH = inputAreaH}\n    state._lyricNotesTokenBounds = tokenBounds\nend\n\nfunction ui.renderTextInputWindow(state, gfx, ctx)\n    _gfx = gfx\n    if gfx.getScreenWidth  then SCREEN_W = gfx.getScreenWidth()  end\n    if gfx.getScreenHeight then SCREEN_H = gfx.getScreenHeight() end\n\n    gfx.setDarkMode(state.darkMode == true)\n    gfx.clear(\"white\")\n\n    local buf = state.solfegeInputBuffer or \"\"\n    local lines = {}\n    for line in (buf .. \"\\n\"):gmatch(\"([^\\n]*)\\n\") do table.insert(lines, line) end\n    if #lines == 0 then lines = {\"\"} end\n    local lineCount = math.max(1, #lines)\n    local active = state.solfegeInputActive\n    SOLFEGE_LINE_H = solfegeLineHeight(state)\n    local textFontStyle = solfegeTextFontStyle(state)\n\n    -- Layout: fill whole window; button strip at top, text box below\n    local btnStripX, btnStripY, btnStripW = 0, 0, SCREEN_W\n    local boxX = SOLFEGE_PAD\n    local boxY = SOLFEGE_BTN_ROW_H\n    local boxW = SCREEN_W - SOLFEGE_PAD * 2\n    local boxH = SCREEN_H - SOLFEGE_BTN_ROW_H - SOLFEGE_PAD\n    local maxVisLines = math.max(1, math.floor((boxH - SOLFEGE_BOX_PAD * 2) / SOLFEGE_LINE_H))\n\n    -- Background\n    drawThemeRect(gfx, state, \"panel\", 0, 0, SCREEN_W, SCREEN_H)\n\n    -- Button strip: top row for mode/dock, second row for text tools\n    do\n        local btnW, btnH = _touchMode and 48 or 20, _touchMode and 40 or 16\n        local btnGap = _touchMode and 10 or 5\n        local rowTopY = btnStripY + 4\n        local rowBottomY = rowTopY\n        gfx.setFont(\"normal\")\n\n        local mx2h = state.mouseX or -1\n        local my2h = state.mouseY or -1\n        local function drawBtn(x, y, w, h, isActive)\n            local hov = mx2h >= x and mx2h < x+w and my2h >= y and my2h < y+h\n            if isActive then\n                setThemeColor(gfx, state, \"buttonActive\")\n            elseif hov then\n                setThemeColor(gfx, state, \"buttonHover\")\n            else\n                setThemeColor(gfx, state, \"button\")\n            end\n            gfx.fillRect(x, y, w, h)\n            if isActive then\n                setThemeColor(gfx, state, \"buttonActiveBorder\")\n            elseif hov then\n                setThemeColor(gfx, state, \"buttonBorder\")\n            else\n                setThemeColor(gfx, state, \"buttonBorder\")\n            end\n            gfx.drawRect(x, y, w, h)\n            if isActive then\n                gfx.setColor(255, 255, 255, 255)\n            else\n                setThemeColor(gfx, state, \"text\")\n            end\n        end\n\n        local curMode = state.solfegeTextMode or \"both\"\n        local modeDefs = {\n        }\n        local modeBtns = {}\n        local mX = btnStripX + SOLFEGE_PAD\n        for _, md in ipairs(modeDefs) do\n            local bw = md.w or btnW\n            local isActive = (curMode == md.mode)\n            drawBtn(mX, rowTopY, bw, btnH, isActive)\n            local lw, lh = gfx.getTextSize(md.label)\n            gfx.drawText(md.label, mX + math.floor((bw - lw) / 2), rowTopY + math.floor((btnH - lh) / 2))\n            modeBtns[#modeBtns + 1] = {x = mX, y = rowTopY, w = bw, h = btnH, mode = md.mode}\n            mX = mX + bw + btnGap\n        end\n        state._solfegeModeBtns = modeBtns\n\n        state._solfegelyricNotesPanelBtn = nil\n\n        state._solfegeLyricsWindowBtn = nil\n\n        state._solfegeMainWindowBtn = nil\n        state._solfegeDockBtns = nil\n\n        state._solfegeSizeBtn = nil\n        state._solfegeSizeMenuItems = nil\n        local toolX = btnStripX + SOLFEGE_PAD\n\n        state._solfegeBreakToggleBtn = nil\n\n        state._solfegeSpellBtn = nil\n\n        state._solfegeCopyBtn = nil\n        state._solfegeCutBtn = nil\n        state._solfegePasteBtn = nil\n        state._solfegeNoteLengthBtn = nil\n        state._solfegeOctToggleBtn = nil\n\n        state._solfegeTemplateBtn = nil\n        state._solfegeTemplateMenuOpen = false\n        state._solfegeAllOptionsOpen = false\n        state._solfegeAllOptionsBtn = nil\n        state._solfegeKeyMinusBtn = nil\n        state._solfegeKeyPlusBtn = nil\n        state._solfegeOctMinusBtn = nil\n        state._solfegeOctPlusBtn = nil\n\n        state._solfegeParagraphBtn = nil\n    end\n\n    -- Build word-wrapped display lines\n    gfx.setFont(textFontStyle)\n    local textW = boxW - SOLFEGE_BOX_PAD * 2 - SOLFEGE_TEXT_INSET - 4\n    local lineStartOfs = {}\n    do\n        local lpos = 1\n        for li = 1, lineCount do\n            lineStartOfs[li] = lpos\n            lpos = lpos + #(lines[li] or \"\") + 1\n        end\n    end\n    local dispLines = {}\n    for li = 1, lineCount do\n        local src = lines[li] or \"\"\n        local loBase = lineStartOfs[li]\n        if src == \"\" or gfx.getTextSize(src) <= textW then\n            dispLines[#dispLines + 1] = {text = src, ln = li, first = true, bufOfs = loBase}\n        else\n            local wds, wdOfs = {}, {}\n            local si = 1\n            for w in src:gmatch(\"%S+\") do\n                local found = src:find(w, si, true)\n                wds[#wds + 1] = w\n                wdOfs[#wdOfs + 1] = found - 1\n                si = found + #w\n            end\n            local cur, firstWrap, segFirst = \"\", true, 1\n            for wi, w in ipairs(wds) do\n                local cand = cur == \"\" and w or (cur .. \" \" .. w)\n                if gfx.getTextSize(cand) <= textW then\n                    cur = cand\n                else\n                    dispLines[#dispLines + 1] = {text = cur, ln = li, first = firstWrap, bufOfs = loBase + wdOfs[segFirst]}\n                    firstWrap = false; cur = w; segFirst = wi\n                end\n            end\n            dispLines[#dispLines + 1] = {text = cur, ln = li, first = firstWrap, bufOfs = loBase + (wdOfs[segFirst] or 0)}\n        end\n    end\n\n    -- Deferred up/down navigation: needs dispLines + gfx for pixel measurements\n    if active and state._pendingSolfegeUpDown then\n        local ud = state._pendingSolfegeUpDown\n        state._pendingSolfegeUpDown = nil\n        local curAbsPos = state.solfegeInputCursor or (#buf + 1)\n\n        -- Find which display line the cursor is on\n        local curIdx = #dispLines\n        for vi = #dispLines, 1, -1 do\n            if (dispLines[vi].bufOfs or 1) <= curAbsPos then curIdx = vi; break end\n        end\n\n        -- Compute or restore sticky X (pixel offset from left edge of line text)\n        local sx = state._solfegeStickyX\n        if not sx then\n            local cdl = dispLines[curIdx]\n            local co = math.max(0, math.min(curAbsPos - (cdl.bufOfs or 1), #(cdl.text or \"\")))\n            gfx.setFont(solfegeTextFontStyle(state))\n            sx = gfx.getTextSize((cdl.text or \"\"):sub(1, co))\n            state._solfegeStickyX = sx\n        end\n\n        local ti = curIdx + ud.dir\n        if ti >= 1 and ti <= #dispLines then\n            local tdl = dispLines[ti]\n            local lt  = tdl.text or \"\"\n            local bo  = #lt  -- default: end of line\n            for ci = 0, #lt - 1 do\n                local xL = gfx.getTextSize(lt:sub(1, ci))\n                local xR = gfx.getTextSize(lt:sub(1, ci + 1))\n                if sx <= (xL + xR) / 2 then bo = ci; break end\n            end\n            local newPos = (tdl.bufOfs or 1) + bo\n            if ud.shift then\n                if not state.solfegeSelAnchor then state.solfegeSelAnchor = curAbsPos end\n                state.solfegeSelFocus = newPos\n            else\n                state.solfegeSelAnchor = nil\n                state.solfegeSelFocus  = nil\n            end\n            state.solfegeInputCursor = (newPos > #buf) and nil or newPos\n            state._solfegeLastCursorActivity = os.clock()\n        end\n    end\n    local visLineCount = math.max(1, #dispLines)\n    state._solfegeVisLineCount = visLineCount\n    state._solfegeMaxVisLines = maxVisLines\n\n    -- Playback token highlight range (steps / lyrics / both modes)\n    local textMode = state.solfegeTextMode or \"both\"\n    local pbRange = nil\n    if state.isPlaying then\n        local stepIdx = state.currentPlaybackStep\n        local seq = state.sequence\n        local seqLen = state.sequenceLength or 0\n        if stepIdx and stepIdx >= 1 and seqLen > 0 then\n            local targetTok = nil\n            local tokCount = 0\n            for i = 1, seqLen do\n                local step = seq and seq[i]\n                local hasTok\n                if textMode == \"lyrics\" then\n                    hasTok = step and step.lyric and step.lyric ~= \"\"\n                else\n                    hasTok = true\n                end\n                if hasTok then\n                    tokCount = tokCount + 1\n                    if i == stepIdx then targetTok = tokCount; break end\n                end\n            end\n            if targetTok then\n                local scanPos = 1\n                if textMode == \"steps\" then\n                    local nl = buf:find(\"\\n\", 1, true)\n                    if nl and buf:sub(1, 4) == \"Key:\" then scanPos = nl + 1 end\n                end\n                local bufLen = #buf\n                local tokNum = 0\n                while scanPos <= bufLen do\n                    while scanPos <= bufLen and (buf:sub(scanPos,scanPos) == \" \" or buf:sub(scanPos,scanPos) == \"\\n\") do\n                        scanPos = scanPos + 1\n                    end\n                    if scanPos > bufLen then break end\n                    local tokStart = scanPos\n                    while scanPos <= bufLen and buf:sub(scanPos,scanPos) ~= \" \" and buf:sub(scanPos,scanPos) ~= \"\\n\" do\n                        scanPos = scanPos + 1\n                    end\n                    tokNum = tokNum + 1\n                    if tokNum == targetTok then\n                        pbRange = {bufStart = tokStart, bufEnd = scanPos}\n                        break\n                    end\n                end\n            end\n        end\n    end\n\n    local maxSL = math.max(0, visLineCount - maxVisLines)\n    local scrollLine = math.max(0, math.min(state.solfegeScrollLine or 0, maxSL))\n    if active and not state._pendingSolfegeClick then\n        local curBufPos = state.solfegeInputCursor or (#buf + 1)\n        local cursorLine = visLineCount\n        for vi = #dispLines, 1, -1 do\n            if (dispLines[vi].bufOfs or 1) <= curBufPos then cursorLine = vi; break end\n        end\n        if cursorLine > scrollLine + maxVisLines then scrollLine = cursorLine - maxVisLines\n        elseif cursorLine <= scrollLine then scrollLine = math.max(0, cursorLine - 1) end\n    elseif pbRange and not active then\n        local pbLine = visLineCount\n        for vi = #dispLines, 1, -1 do\n            if (dispLines[vi].bufOfs or 1) <= pbRange.bufStart then pbLine = vi; break end\n        end\n        if pbLine > scrollLine + maxVisLines then scrollLine = pbLine - maxVisLines\n        elseif pbLine <= scrollLine then scrollLine = math.max(0, pbLine - 1) end\n    end\n    -- Auto-scroll when drag-select extends above or below the text box (~12 lines/sec)\n    if active and state._solfegeDragSel and state._solfegeDragRawY then\n        local rawY = state._solfegeDragRawY\n        local now = os.clock()\n        local lastST = state._solfegeDragScrollTime or 0\n        if (now - lastST) >= 0.08 then\n            if rawY < boxY and scrollLine > 0 then\n                scrollLine = scrollLine - 1\n                state._solfegeDragScrollTime = now\n            elseif rawY >= boxY + boxH and scrollLine < maxSL then\n                scrollLine = scrollLine + 1\n                state._solfegeDragScrollTime = now\n            end\n        end\n    end\n    state.solfegeScrollLine = scrollLine\n\n    -- White input box\n    setThemeColor(gfx, state, \"input\")\n    gfx.fillRect(boxX, boxY, boxW, boxH)\n    setThemeColor(gfx, state, active and \"inputActive\" or \"inputIdle\")\n    gfx.drawRect(boxX, boxY, boxW, boxH)\n\n    gfx.setFont(textFontStyle)\n    local textX = boxX + SOLFEGE_BOX_PAD + SOLFEGE_TEXT_INSET\n    local firstLineY = boxY + SOLFEGE_BOX_PAD\n    local _, _fontH = gfx.getTextSize(\"|\")\n    local cursorH = (_fontH and _fontH > 0) and _fontH or (SOLFEGE_LINE_H - 2)\n\n    -- Build visible display lines with charXs for click-to-cursor\n    local visibleDls = {}\n    do\n        for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n            local dl = dispLines[vi]\n            local visRow = vi - scrollLine\n            local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n            local lineText = dl.text or \"\"\n            local charXs = {[0] = 0}\n            for ci = 1, #lineText do charXs[ci] = gfx.getTextSize(lineText:sub(1, ci)) end\n            visibleDls[#visibleDls + 1] = {text = lineText, bufOfs = dl.bufOfs or 1, lineY = ly, lineH = SOLFEGE_LINE_H, charXs = charXs}\n        end\n        state._solfegeDispLines = visibleDls\n        state._solfegeTextX = textX\n\n        -- Apply deferred click-to-cursor\n        if state._pendingSolfegeClick then\n            local pc = state._pendingSolfegeClick\n            state._pendingSolfegeClick = nil\n            local px, py = pc.x, pc.y\n            local clickedDl = visibleDls[#visibleDls]\n            for _, dl in ipairs(visibleDls) do\n                if py < dl.lineY + dl.lineH then clickedDl = dl; break end\n            end\n            if clickedDl then\n                local relX = px - textX\n                local lineText = clickedDl.text or \"\"\n                local cxs = clickedDl.charXs or {[0] = 0}\n                local bestOffset = #lineText\n                for ci = 0, #lineText - 1 do\n                    local midX = ((cxs[ci] or 0) + (cxs[ci + 1] or 0)) / 2\n                    if relX <= midX then bestOffset = ci; break end\n                end\n                local newCursor = (clickedDl.bufOfs or 1) + bestOffset\n                local newAbsPos = (newCursor > #buf) and (#buf + 1) or newCursor\n                state.solfegeInputCursor = (newCursor > #buf) and nil or newCursor\n                state._solfegeLastCursorActivity = os.clock()\n                if pc.isTripleClick then\n                    -- Select entire buffer line\n                    local ls = solfegeLineStart(buf, newAbsPos)\n                    local le = solfegeLineEnd(buf, newAbsPos)\n                    state.solfegeSelAnchor = ls\n                    state.solfegeSelFocus  = le\n                    state.solfegeInputCursor = (le > #buf) and nil or le\n                elseif pc.isDoubleClick and pc.isSelExtend then\n                    -- Extend selection to word boundary\n                    local ws = solfegeWordStart(buf, newAbsPos)\n                    local we = solfegeWordEnd(buf, ws)\n                    if not state.solfegeSelAnchor then\n                        state.solfegeSelAnchor = state.solfegeInputCursor or (#buf + 1)\n                    end\n                    if newAbsPos < state.solfegeSelAnchor then\n                        state.solfegeSelFocus = ws\n                    else\n                        state.solfegeSelFocus = we\n                    end\n                    state.solfegeInputCursor = (state.solfegeSelFocus > #buf) and nil or state.solfegeSelFocus\n                elseif pc.isDoubleClick then\n                    local ws = solfegeWordStart(buf, newAbsPos)\n                    local we = solfegeWordEnd(buf, ws)\n                    if we > ws then\n                        state.solfegeSelAnchor = ws\n                        state.solfegeSelFocus  = we\n                        state.solfegeInputCursor = (we > #buf) and nil or we\n                    end\n                elseif pc.isSelExtend then\n                    if not state.solfegeSelAnchor then\n                        -- Anchor at cursor (where mouse-down landed), not at current drag pos\n                        state.solfegeSelAnchor = state.solfegeInputCursor or (#buf + 1)\n                    end\n                    state.solfegeSelFocus = newAbsPos\n                else\n                    state.solfegeSelAnchor = nil\n                    state.solfegeSelFocus  = nil\n                end\n            end\n        end\n    end\n\n    -- Draw text content\n    if buf == \"\" and not active then\n        gfx.setColor(160, 160, 160, 255)\n        local curMode = state.solfegeTextMode or \"both\"\n        if curMode == \"steps\" or curMode == \"both\" then\n            gfx.drawText(\"e.g.  Do  Re4  Mi/2  Fa/d4|word  --\", textX, firstLineY)\n            gfx.setFont(\"small\")\n            gfx.drawText(\"syllable  octave  /duration  |lyric\", textX, firstLineY + SOLFEGE_LINE_H)\n            gfx.setFont(textFontStyle)\n        else\n            gfx.drawText(\"Type lyrics here...\", textX, firstLineY)\n        end\n    else\n        local showBreaks = (state.solfegeShowBreaks ~= false)\n        -- Playback token highlight\n        if pbRange then\n            gfx.setColor(255, 185, 45, 160)\n            local pbLo, pbHi = pbRange.bufStart, pbRange.bufEnd\n            for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n                local dl = dispLines[vi]\n                if dl then\n                    local lineOfs = dl.bufOfs or 1\n                    local lineText = dl.text or \"\"\n                    local lineEnd = lineOfs + #lineText\n                    if pbLo < lineEnd and pbHi > lineOfs then\n                        local visRow = vi - scrollLine\n                        local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n                        local vdl = visibleDls[visRow]\n                        local cxs = (vdl and vdl.charXs) or {[0]=0}\n                        local startChar = math.max(0, pbLo - lineOfs)\n                        local endChar = math.min(#lineText, pbHi - lineOfs)\n                        local x1 = textX + (cxs[startChar] or gfx.getTextSize(lineText:sub(1, startChar)))\n                        local x2 = textX + (cxs[endChar] or gfx.getTextSize(lineText:sub(1, endChar)))\n                        if x2 > x1 then\n                            gfx.fillRect(x1, ly, x2 - x1, cursorH)\n                        end\n                    end\n                end\n            end\n        end\n        -- Selection highlight\n        do\n            local selLo, selHi = nil, nil\n            if state.solfegeSelAnchor and state.solfegeSelFocus\n               and state.solfegeSelAnchor ~= state.solfegeSelFocus then\n                local a, f = state.solfegeSelAnchor, state.solfegeSelFocus\n                selLo = math.min(a, f); selHi = math.max(a, f)\n            end\n            local _barBottomY, _barMinX, _barMaxX = nil, nil, nil\n            if selLo then\n                gfx.setColor(100, 160, 230, 120)\n                for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n                    local dl = dispLines[vi]\n                    if dl then\n                        local lineOfs = dl.bufOfs or 1\n                        local lineText = dl.text or \"\"\n                        local lineEnd  = lineOfs + #lineText\n                        if selLo < lineEnd and selHi > lineOfs then\n                            local visRow = vi - scrollLine\n                            local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n                            local vdl = visibleDls[visRow]\n                            local cxs = (vdl and vdl.charXs) or {[0]=0}\n                            local startChar = math.max(0, selLo - lineOfs)\n                            local endChar   = math.min(#lineText, selHi - lineOfs)\n                            local x1 = textX + (cxs[startChar] or gfx.getTextSize(lineText:sub(1, startChar)))\n                            local x2\n                            if selHi > lineEnd then\n                                x2 = boxX + boxW - SOLFEGE_BOX_PAD - 1\n                            else\n                                x2 = textX + (cxs[endChar] or gfx.getTextSize(lineText:sub(1, endChar)))\n                            end\n                            if x2 > x1 then\n                                gfx.fillRect(x1, ly, x2 - x1, cursorH)\n                                _barBottomY = ly + cursorH\n                                if _barMinX == nil or x1 < _barMinX then _barMinX = x1 end\n                                if _barMaxX == nil or x2 > _barMaxX then _barMaxX = x2 end\n                            end\n                        end\n                    end\n                end\n            end\n            drawSolfegeSelBar(gfx, state, _barBottomY, _barMinX, _barMaxX, boxX, boxY, boxW, boxH, textFontStyle)\n        end\n        for vi = scrollLine + 1, math.min(scrollLine + maxVisLines, visLineCount) do\n            local visRow = vi - scrollLine\n            local ly = firstLineY + (visRow - 1) * SOLFEGE_LINE_H\n            local dl = dispLines[vi]\n            if showBreaks and dl and dl.first and dl.ln > 1 then\n                gfx.setColor(180, 180, 180, 255)\n                gfx.drawText(tostring(dl.ln), boxX - SOLFEGE_PAD + 1, ly)\n            end\n            drawSolfegeLineText(gfx, dl and dl.text or \"\", textX, ly)\n            if showBreaks and dl and dl.text and dl.text ~= \"\" then\n                local nextDl = dispLines[vi + 1]\n                if nextDl and nextDl.ln ~= dl.ln then\n                    local tw = gfx.getTextSize(dl.text)\n                    local markerX = textX + tw + 3\n                    gfx.setColor(190, 190, 190, 200)\n                    if nextDl.text == \"\" then\n                        gfx.drawText(\"\\182\", markerX, ly)\n                    else\n                        gfx.drawText(\"\\226\\134\\181\", markerX, ly)\n                    end\n                end\n            end\n            -- Spell check underlines\n            if state._solfegeSpellErrors and dl then\n                local lineText = dl.text or \"\"\n                local lineOfs = dl.bufOfs or 1\n                for _, err in ipairs(state._solfegeSpellErrors) do\n                    local rs = err.bufStart - lineOfs + 1\n                    local re = err.bufEnd - lineOfs + 1\n                    if re >= 1 and rs <= #lineText then\n                        local cs = math.max(1, rs)\n                        local ce = math.min(#lineText, re)\n                        local ex1 = textX + (cs > 1 and gfx.getTextSize(lineText:sub(1, cs-1)) or 0)\n                        local ex2 = textX + gfx.getTextSize(lineText:sub(1, ce))\n                        gfx.setColor(220, 55, 55, 230)\n                        gfx.drawLine(ex1, ly + SOLFEGE_LINE_H - 2, ex2, ly + SOLFEGE_LINE_H - 2)\n                    end\n                end\n            end\n        end\n        -- Cursor: always visible while dragging\n        local _tdDrag2 = state._solfegeTextDragMove\n        local _tdActive2 = _tdDrag2 and _tdDrag2.active and _tdDrag2.moved\n        if active and not _tdActive2 then\n            local sinceAct = os.clock() - (state._solfegeLastCursorActivity or 0)\n            local blinkOn = state._solfegeDragSel or sinceAct < 0.5 or (math.floor(os.clock() * 2) % 2 == 0)\n            if blinkOn then\n                local curBufPos2 = state.solfegeInputCursor or (#buf + 1)\n                local cursorLine = visLineCount\n                for vi = #dispLines, 1, -1 do\n                    if (dispLines[vi].bufOfs or 1) <= curBufPos2 then cursorLine = vi; break end\n                end\n                if cursorLine > scrollLine and cursorLine <= scrollLine + maxVisLines then\n                    local dl2 = dispLines[cursorLine]\n                    local lineText2 = dl2 and dl2.text or \"\"\n                    local charsIn = math.max(0, math.min(curBufPos2 - (dl2 and dl2.bufOfs or 1), #lineText2))\n                    local prefix2 = lineText2:sub(1, charsIn)\n                    local tw = (prefix2 ~= \"\") and gfx.getTextSize(prefix2) or 0\n                    local cursorX = math.max(textX, math.min(boxX + boxW - SOLFEGE_BOX_PAD - 4, textX + tw))\n                    local lineTop = firstLineY + (cursorLine - scrollLine - 1) * SOLFEGE_LINE_H\n                    local cursorY = lineTop\n                    state._solfegeCursorScreenX = cursorX\n                    state._solfegeCursorScreenY = cursorY + cursorH\n                    gfx.setColor(0, 0, 0, 255)\n                    gfx.fillRect(cursorX, cursorY, _touchMode and 2 or 1, cursorH)\n                end\n            end\n        end\n        -- Drop cursor: shown when dragging text to a new position\n        if _tdActive2 and _tdDrag2.dropPos then\n            local dropBufPos = _tdDrag2.dropPos\n            local dropLine = visLineCount\n            for vi = #dispLines, 1, -1 do\n                if (dispLines[vi].bufOfs or 1) <= dropBufPos then dropLine = vi; break end\n            end\n            if dropLine > scrollLine and dropLine <= scrollLine + maxVisLines then\n                local ddl = dispLines[dropLine]\n                local dLineText = ddl and ddl.text or \"\"\n                local dCharsIn = math.max(0, math.min(dropBufPos - (ddl and ddl.bufOfs or 1), #dLineText))\n                local dPrefix = dLineText:sub(1, dCharsIn)\n                local dtw = (dPrefix ~= \"\") and gfx.getTextSize(dPrefix) or 0\n                local dropX = math.max(textX, math.min(boxX + boxW - SOLFEGE_BOX_PAD - 4, textX + dtw))\n                local dropLineTop = firstLineY + (dropLine - scrollLine - 1) * SOLFEGE_LINE_H\n                gfx.setColor(220, 120, 30, 255)\n                gfx.drawLine(dropX, dropLineTop, dropX, dropLineTop + cursorH - 1)\n                gfx.drawLine(dropX + 1, dropLineTop, dropX + 1, dropLineTop + cursorH - 1)\n            end\n        end\n    end\n\n    -- Scrollbar\n    if visLineCount > maxVisLines then\n        local sbX2 = boxX + boxW - 3\n        local sbH2 = math.max(1, boxH - SOLFEGE_BOX_PAD * 2)\n        local thumbH = math.max(4, math.floor(sbH2 * maxVisLines / visLineCount))\n        local thumbY = boxY + SOLFEGE_BOX_PAD + (maxSL > 0 and math.floor((sbH2 - thumbH) * scrollLine / maxSL) or 0)\n        gfx.setColor(210, 210, 210, 220)\n        gfx.fillRect(sbX2, boxY + SOLFEGE_BOX_PAD, 3, sbH2)\n        gfx.setColor(120, 120, 120, 220)\n        gfx.fillRect(sbX2, thumbY, 3, thumbH)\n    end\n\n    drawSolfegeAutocomplete(gfx, state)\n    drawSolfegeTemplatePicker(gfx, state)\n    drawSolfegeSizeMenu(gfx, state)\n    drawSolfegeTemplateMenu(gfx, state)\n    gfx.setColor(0, 0, 0, 255)\n    state._solfegeInputBounds = {x = boxX, y = boxY, w = boxW, h = boxH}\n    if not state._solfegeSizeMenuOpen then state._solfegeSizeMenuItems = nil end\n    state._solfegeFloatTitleBar = nil\n    state._solfegeFloatResizeHandle = nil\n    state._solfegeDragHandle = nil\n\n    -- Context menu overlay\n    drawSolfegeCtxMenu(gfx, state)\nend\n\nfunction ui.renderTextOptionsWindow(state, gfx)\n    _gfx = gfx\n    if gfx.getScreenWidth  then SCREEN_W = gfx.getScreenWidth()  end\n    if gfx.getScreenHeight then SCREEN_H = gfx.getScreenHeight() end\n    gfx.setDarkMode(state.darkMode == true)\n    gfx.clear(\"white\")\n\n    local sections = buildSolfegeOptionsSections(state)\n\n    gfx.setFont(\"normal\")\n    local itemH   = 16\n    local headerH = 14\n    local pad     = 4\n    local mx = state._optWinMouseX or -1\n    local my = state._optWinMouseY or -1\n    local cy = pad\n    local btns = {}\n\n    for _, sec in ipairs(sections) do\n        -- Section header bar\n        gfx.setColor(75, 90, 140, 255)\n        gfx.fillRect(0, cy, SCREEN_W, headerH)\n        gfx.setColor(255, 255, 255, 220)\n        local hw, hh = gfx.getTextSize(sec.header)\n        gfx.drawText(sec.header, pad + 2, cy + math.floor((headerH - hh) / 2))\n        cy = cy + headerH\n\n        for _, item in ipairs(sec.items) do\n            local label = type(item) == \"table\" and item.label or item\n            local text  = type(item) == \"table\" and item.text or item\n            local isHov = text and mx >= 0 and mx < SCREEN_W and my >= cy and my < cy + itemH\n            if isHov then\n                gfx.setColor(210, 225, 250, 220)\n                gfx.fillRect(0, cy, SCREEN_W, itemH)\n            end\n            if text then\n                gfx.setColor(isHov and 20 or 45, isHov and 55 or 55, isHov and 130 or 80, 255)\n            else\n                gfx.setColor(120, 120, 135, 255)\n            end\n            gfx.drawText(label, pad + 4, cy + 1)\n            if text then\n                btns[#btns + 1] = {x = 0, y = cy, w = SCREEN_W, h = itemH, text = text}\n            end\n            cy = cy + itemH\n        end\n        cy = cy + pad\n    end\n\n    state._optWinBtns = btns\nend\n\nui.SOLFEGE_TEMPLATE_CATEGORIES = SOLFEGE_TEMPLATE_CATEGORIES\n\nreturn ui\n","web_audio.lua":"local WebAudio = {}\n\nlocal function getBridge()\n    return rawget(_G, \"WebHost\") and _G.WebHost.audio or nil\nend\n\nfunction WebAudio.new()\n    local self = {}\n    local bridge = nil\n\n    local function b()\n        if not bridge then bridge = getBridge() end\n        return bridge\n    end\n\n    self.kWaveSine = \"sine\"\n    self.kWaveTriangle = \"triangle\"\n    self.kWaveSquare = \"square\"\n    self.kWaveSawtooth = \"sawtooth\"\n    self.kWaveWarm = \"warm\"\n    self.kWaveVocal = \"vocal\"\n    self.kWaveFlute = \"flute\"\n\n    function self.init()\n        bridge = getBridge()\n        if bridge and bridge.init then bridge.init() end\n    end\n\n    function self.getVolume()\n        local br = b()\n        if br and br.getVolume then return br.getVolume() end\n        return 1\n    end\n\n    function self.setVolume(volume)\n        local br = b()\n        if br and br.setVolume then br.setVolume(volume) end\n    end\n\n    function self.synthNew(waveType)\n        local br = b()\n        if not br or not br.createSynth then return nil end\n\n        local synthId = br.createSynth(waveType or \"sine\")\n        local synth = {}\n        local volume = 0.5\n        local adsr = { a = 0.03, d = 0.15, s = 0.6, r = 0.4 }\n\n        function synth:setVolume(v)\n            volume = math.max(0, math.min(1, tonumber(v) or 0.5))\n            br.setSynthVolume(synthId, volume)\n        end\n\n        function synth:setADSR(a, d, s, r)\n            adsr.a = math.max(0.001, tonumber(a) or 0.03)\n            adsr.d = math.max(0.001, tonumber(d) or 0.15)\n            adsr.s = math.max(0, math.min(1, tonumber(s) or 0.6))\n            adsr.r = math.max(0.001, tonumber(r) or 0.4)\n            br.setSynthADSR(synthId, adsr.a, adsr.d, adsr.s, adsr.r)\n        end\n\n        function synth:playNote(freq, vel, dur)\n            br.playSynthNote(synthId, freq or 440, vel or 1, dur or 0.5)\n        end\n\n        function synth:playLoop(freq, vel)\n            br.playSynthNote(synthId, freq or 440, vel or 1, 10)\n        end\n\n        function synth:stop()\n            br.stopSynth(synthId)\n        end\n\n        function synth:prewarmNote(_freq, _vel, _dur)\n        end\n\n        return synth\n    end\n\n    function self.sampleNew(_durationSeconds)\n        return nil\n    end\n\n    function self.micStartListening()\n        return false\n    end\n\n    function self.micRecordToSample(_buffer, _callback)\n        return false\n    end\n\n    function self.micStopListening() end\n    function self.micStopRecording() end\n\n    function self.micListInputDevices()\n        return {}\n    end\n\n    function self.micSetInputDevice(_index)\n        return false\n    end\n\n    return self\nend\n\nreturn WebAudio\n","web_graphics.lua":"local WebGraphics = {}\n\nlocal function getBridge()\n    return rawget(_G, \"WebHost\") and _G.WebHost.graphics or nil\nend\n\nfunction WebGraphics.new()\n    local self = {}\n    local bridge = nil\n\n    function self.init()\n        bridge = getBridge()\n    end\n\n    function self.clear(color)\n        if bridge then bridge.clear(color or \"white\") end\n    end\n\n    function self.drawText(text, x, y)\n        if bridge then bridge.drawText(tostring(text), x, y) end\n    end\n\n    function self.drawRect(x, y, width, height)\n        if bridge then bridge.drawRect(x, y, width, height) end\n    end\n\n    function self.fillRect(x, y, width, height)\n        if bridge then bridge.fillRect(x, y, width, height) end\n    end\n\n    function self.drawRoundRect(x, y, width, height, radius)\n        if bridge then bridge.drawRoundRect(x, y, width, height, radius or 0) end\n    end\n\n    function self.fillRoundRect(x, y, width, height, radius)\n        if bridge then bridge.fillRoundRect(x, y, width, height, radius or 0) end\n    end\n\n    function self.drawLine(x1, y1, x2, y2)\n        if bridge then bridge.drawLine(x1, y1, x2, y2) end\n    end\n\n    function self.setFont(style)\n        if bridge then bridge.setFont(style or \"normal\") end\n    end\n\n    function self.getTextSize(text)\n        if bridge then\n            local result = bridge.getTextSize(tostring(text or \"\"))\n            if result then\n                return result.w or 0, result.h or 12\n            end\n        end\n        return #tostring(text or \"\") * 7, 12\n    end\n\n    function self.setDrawMode(mode)\n        if bridge then bridge.setDrawMode(mode or \"copy\") end\n    end\n\n    function self.setLineWidth(width)\n        if bridge then bridge.setLineWidth(width or 1) end\n    end\n\n    function self.drawCircle(x, y, radius)\n        if bridge then bridge.drawCircle(x, y, radius) end\n    end\n\n    function self.fillCircle(x, y, radius)\n        if bridge then bridge.fillCircle(x, y, radius) end\n    end\n\n    function self.drawEllipseInRect(x, y, width, height)\n        if bridge then bridge.drawEllipseInRect(x, y, width, height) end\n    end\n\n    function self.drawPolygon(points)\n        if not bridge or type(points) ~= \"table\" or #points < 4 then return end\n        local flat = {}\n        for i = 1, #points do flat[i] = points[i] end\n        bridge.drawPolygon(flat)\n    end\n\n    function self.fillPolygon(points)\n        if not bridge or type(points) ~= \"table\" or #points < 4 then return end\n        local flat = {}\n        for i = 1, #points do flat[i] = points[i] end\n        bridge.fillPolygon(flat)\n    end\n\n    function self.loadImage(path)\n        if bridge then return bridge.loadImage(path) end\n        return nil\n    end\n\n    function self.drawImage(image, x, y)\n        if bridge then bridge.drawImage(image, x, y) end\n    end\n\n    function self.drawImageScaled(image, x, y, scaleX, scaleY)\n        if bridge then bridge.drawImageScaled(image, x, y, scaleX, scaleY) end\n    end\n\n    function self.getImageSize(image)\n        if bridge then\n            local result = bridge.getImageSize(image)\n            if result then return result.w or 0, result.h or 0 end\n        end\n        return 0, 0\n    end\n\n    function self.setColor(r, g, b_val, a)\n        if bridge then bridge.setColor(r or 0, g or 0, b_val or 0, a or 255) end\n    end\n\n    function self.setDarkMode(enabled)\n        if bridge then bridge.setDarkMode(enabled == true) end\n    end\n\n    function self.getScreenWidth()\n        if bridge then return bridge.getScreenWidth() end\n        return 400\n    end\n\n    function self.getScreenHeight()\n        if bridge then return bridge.getScreenHeight() end\n        return 300\n    end\n\n    function self.update()\n        if bridge and bridge.update then bridge.update() end\n    end\n\n    function self.cleanup()\n        if bridge and bridge.cleanup then bridge.cleanup() end\n    end\n\n    return self\nend\n\nreturn WebGraphics\n","web_input.lua":"local WebInput = {}\n\nlocal function getBridge()\n    return rawget(_G, \"WebHost\") and _G.WebHost.input or nil\nend\n\nlocal function normalizeKey(key)\n    if not key then return nil end\n    local lowered = string.lower(tostring(key))\n    if lowered == \"enter\" then return \"return\" end\n    return lowered\nend\n\nfunction WebInput.new()\n    local self = {\n        callbacks = {},\n        buttonStates = {\n            primary = false,\n            secondary = false,\n            left = false,\n            right = false,\n            up = false,\n            down = false\n        }\n    }\n\n    local bridge = nil\n\n    local function setButtonStateForKey(key, pressed)\n        if key == \"left\" then self.buttonStates.left = pressed\n        elseif key == \"right\" then self.buttonStates.right = pressed\n        elseif key == \"up\" then self.buttonStates.up = pressed\n        elseif key == \"down\" then self.buttonStates.down = pressed\n        elseif key == \"return\" or key == \"space\" then self.buttonStates.primary = pressed\n        elseif key == \"escape\" or key == \"backspace\" then self.buttonStates.secondary = pressed\n        end\n    end\n\n    local function dispatchPressForKey(key)\n        if key == \"left\" and self.callbacks.onLeft then self.callbacks.onLeft()\n        elseif key == \"right\" and self.callbacks.onRight then self.callbacks.onRight()\n        elseif key == \"up\" and self.callbacks.onUp then self.callbacks.onUp()\n        elseif key == \"down\" and self.callbacks.onDown then self.callbacks.onDown()\n        elseif key == \"return\" and self.callbacks.onPrimary then self.callbacks.onPrimary()\n        elseif key == \"space\" and self.callbacks.onPrimary then self.callbacks.onPrimary()\n        elseif key == \"escape\" and self.callbacks.onSecondary then self.callbacks.onSecondary()\n        elseif key == \"backspace\" and self.callbacks.onSecondary then self.callbacks.onSecondary()\n        end\n        if self.callbacks.onRawKey then\n            self.callbacks.onRawKey(key, true)\n        end\n    end\n\n    function self.init(callbacks)\n        self.callbacks = callbacks or {}\n        bridge = getBridge()\n    end\n\n    function self.update()\n        if not bridge then bridge = getBridge() end\n        if not bridge or not bridge.pollEvents then return end\n\n        local raw = bridge.pollEvents()\n        if not raw or raw == \"\" then return end\n\n        local pos = 1\n        local len = #raw\n        while pos <= len do\n            local nl = raw:find(\"\\n\", pos, true) or (len + 1)\n            local line = raw:sub(pos, nl - 1)\n            pos = nl + 1\n\n            local t = line:sub(1, 1)\n\n            if t == \"K\" then\n                local key = normalizeKey(line:sub(3))\n                local tab2 = line:find(\"\\t\", 3, true)\n                if tab2 then\n                    key = normalizeKey(line:sub(3, tab2 - 1))\n                end\n                setButtonStateForKey(key, true)\n                dispatchPressForKey(key)\n            elseif t == \"U\" then\n                local key = normalizeKey(line:sub(3))\n                setButtonStateForKey(key, false)\n                if self.callbacks.onRawKeyUp then\n                    self.callbacks.onRawKeyUp(key)\n                end\n            elseif t == \"D\" and self.callbacks.onMouseDown then\n                local x, y, btn = line:match(\"^D\\t(%-?%d+)\\t(%-?%d+)\\t(%d+)$\")\n                if x then self.callbacks.onMouseDown(tonumber(x), tonumber(y), tonumber(btn)) end\n            elseif t == \"P\" and self.callbacks.onMouseUp then\n                local x, y, btn = line:match(\"^P\\t(%-?%d+)\\t(%-?%d+)\\t(%d+)$\")\n                if x then self.callbacks.onMouseUp(tonumber(x), tonumber(y), tonumber(btn)) end\n            elseif t == \"M\" and self.callbacks.onMouseMove then\n                local x, y = line:match(\"^M\\t(%-?%d+)\\t(%-?%d+)$\")\n                if x then self.callbacks.onMouseMove(tonumber(x), tonumber(y)) end\n            elseif t == \"d\" and self.callbacks.onMouseDown then\n                local x, y = line:match(\"^d\\t(%-?%d+)\\t(%-?%d+)$\")\n                if x then self.callbacks.onMouseDown(tonumber(x), tonumber(y), 1) end\n            elseif t == \"m\" and self.callbacks.onMouseMove then\n                local x, y = line:match(\"^m\\t(%-?%d+)\\t(%-?%d+)$\")\n                if x then self.callbacks.onMouseMove(tonumber(x), tonumber(y)) end\n            elseif t == \"p\" and self.callbacks.onMouseUp then\n                local x, y = line:match(\"^p\\t(%-?%d+)\\t(%-?%d+)$\")\n                if x then self.callbacks.onMouseUp(tonumber(x), tonumber(y), 1) end\n            elseif t == \"W\" and self.callbacks.onAnalog then\n                local v = tonumber(line:sub(3))\n                if v then\n                    local delta = -v\n                    self.callbacks.onAnalog(delta, delta * 1.5)\n                end\n            elseif t == \"V\" and self.callbacks.onRawKey then\n                local text = line:sub(3)\n                if text then\n                    for i = 1, #text do\n                        self.callbacks.onRawKey(text:sub(i, i), true)\n                    end\n                end\n            end\n        end\n    end\n\n    function self.isButtonPressed(button)\n        if self.buttonStates[button] ~= nil then\n            return self.buttonStates[button]\n        end\n        return false\n    end\n\n    function self.updateCallbacks(callbacks)\n        self.callbacks = callbacks or {}\n    end\n\n    function self.cleanup()\n        for key in pairs(self.buttonStates) do\n            self.buttonStates[key] = false\n        end\n    end\n\n    return self\nend\n\nreturn WebInput\n","web_storage.lua":"local WebStorage = {}\n\nlocal json = nil\n\nlocal function getJson()\n    if json then return json end\n    local ok, mod = pcall(require, \"dkjson\")\n    if ok then json = mod; return json end\n    ok, mod = pcall(require, \"cjson\")\n    if ok then json = mod; return json end\n    local g = rawget(_G, \"json\")\n    if g then json = g; return json end\n    return nil\nend\n\nlocal function encodeJson(data)\n    local codec = getJson()\n    if codec and codec.encode then\n        local ok, encoded = pcall(codec.encode, data)\n        if ok then return encoded end\n    end\n\n    if type(data) == \"string\" then return data end\n    if type(data) == \"number\" then return tostring(data) end\n    if type(data) == \"boolean\" then return data and \"true\" or \"false\" end\n    return nil\nend\n\nlocal function decodeJson(raw)\n    local codec = getJson()\n    if codec and codec.decode then\n        local ok, decoded = pcall(codec.decode, raw)\n        if ok then return decoded end\n    end\n    return nil\nend\n\nlocal function getBridge()\n    return rawget(_G, \"WebHost\") and _G.WebHost.storage or nil\nend\n\nfunction WebStorage.new()\n    local self = {}\n    local bridge = nil\n\n    local function b()\n        if not bridge then bridge = getBridge() end\n        return bridge\n    end\n\n    function self.read(filename)\n        local br = b()\n        if br and br.getItem then\n            local raw = br.getItem(filename)\n            if raw and raw ~= \"\" then\n                local decoded = decodeJson(raw)\n                if decoded ~= nil then return decoded end\n                return raw\n            end\n        end\n        return nil\n    end\n\n    function self.write(filename, data)\n        local br = b()\n        if not br or not br.setItem then return false end\n        local encoded = encodeJson(data)\n        if not encoded then return false end\n        br.setItem(filename, encoded)\n        local od = rawget(_G, \"_oneDriveBridge\")\n        if od and od.isSignedIn and od.isSignedIn() and od.syncFile then\n            od.syncFile(filename, encoded)\n        end\n        return true\n    end\n\n    function self.delete(filename)\n        local br = b()\n        if br and br.removeItem then\n            br.removeItem(filename)\n            return true\n        end\n        return false\n    end\n\n    function self.list(_prefix)\n        local br = b()\n        if br and br.listKeys then\n            return br.listKeys(_prefix or \"\")\n        end\n        return {}\n    end\n\n    function self.readBinary(filename)\n        -- Support reading binary files mounted by wasmoon (drag-and-drop imports)\n        local f = io.open(filename, \"rb\")\n        if f then\n            local data = f:read(\"*a\")\n            f:close()\n            return data\n        end\n        return nil, \"File not found: \" .. tostring(filename)\n    end\n\n    function self.updateTemplateInCloud(_templatePayload)\n        return false\n    end\n\n    function self.getStorageRoot()\n        return nil\n    end\n\n    return self\nend\n\nreturn WebStorage\n","web_system.lua":"local WebSystem = {}\n\nfunction WebSystem.new()\n    local impl = {}\n    local startTime = os.clock()\n\n    impl.getCurrentTimeMilliseconds = function()\n        local bridge = rawget(_G, \"WebHost\") and _G.WebHost.system or nil\n        if bridge and bridge.now then\n            return bridge.now()\n        end\n        return math.floor((os.clock() - startTime) * 1000)\n    end\n\n    impl.setAutoLockDisabled = function(enabled)\n        local bridge = rawget(_G, \"WebHost\") and _G.WebHost.system or nil\n        if bridge and bridge.setAutoLockDisabled then\n            bridge.setAutoLockDisabled(enabled)\n        end\n    end\n    impl.isUsbConnected = function() return false end\n    impl.setupMenu = function(_menuItems) end\n    impl.registerLifecycleHandlers = function(_handlers) end\n\n    impl.downloadFile = function(filename, content, mimeType)\n        local host = rawget(_G, \"WebHost\")\n        if host and host.downloadFile then\n            host.downloadFile(filename or \"download\", content or \"\", mimeType or \"application/octet-stream\")\n            return true\n        end\n        return false\n    end\n\n    return impl\nend\n\nreturn WebSystem\n","web_timer.lua":"local WebTimer = {}\n\nfunction WebTimer.new()\n    local self = {}\n    local timers = {}\n    local nextId = 1\n\n    function self.newRepeating(durationMilliseconds, callback)\n        local id = nextId\n        nextId = nextId + 1\n        local timer = {\n            interval = durationMilliseconds / 1000,\n            callback = callback,\n            elapsed = 0,\n            active = true,\n        }\n        timers[id] = timer\n        return {\n            remove = function()\n                timer.active = false\n                timers[id] = nil\n            end\n        }\n    end\n\n    function self.newOneShot(durationMilliseconds, callback)\n        local id = nextId\n        nextId = nextId + 1\n        local timer = {\n            interval = durationMilliseconds / 1000,\n            callback = callback,\n            elapsed = 0,\n            active = true,\n            oneShot = true,\n        }\n        timers[id] = timer\n        return {\n            remove = function()\n                timer.active = false\n                timers[id] = nil\n            end\n        }\n    end\n\n    local lastTime = nil\n\n    function self.update()\n        local bridge = rawget(_G, \"WebHost\") and _G.WebHost.system or nil\n        local now\n        if bridge and bridge.now then\n            now = bridge.now() / 1000\n        else\n            now = os.clock()\n        end\n        local dt = lastTime and (now - lastTime) or (1 / 60)\n        if dt > 0.1 then dt = 0.1 end\n        lastTime = now\n\n        for id, timer in pairs(timers) do\n            if timer.active then\n                timer.elapsed = timer.elapsed + dt\n                if timer.elapsed >= timer.interval then\n                    timer.elapsed = timer.elapsed - timer.interval\n                    if timer.callback then\n                        timer.callback()\n                    end\n                    if timer.oneShot then\n                        timer.active = false\n                        timers[id] = nil\n                    end\n                end\n            end\n        end\n    end\n\n    return self\nend\n\nreturn WebTimer\n"}