diff --git a/assets/disk0/tvdos/TVDOS.SYS b/assets/disk0/tvdos/TVDOS.SYS index c87a370..85a0b43 100644 --- a/assets/disk0/tvdos/TVDOS.SYS +++ b/assets/disk0/tvdos/TVDOS.SYS @@ -1123,6 +1123,8 @@ inputwork.repeatCount = 0; * bits (6, 7) latch in hardware and clear on read, so a one-shot detent fires once. * Every mouse event carries the currently-held key buffer (same shape as key_down) * so handlers can detect modifiers like Shift+wheel via `event.includes()`. + * + * Keysymbol depends on the current keyboard layout, while keycodes stay the same. */ input.withEvent = function(callback) { diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index ba14561..6b2a926 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -312,12 +312,24 @@ const panEffSym = [sym.panset, sym.panri, sym.panle, sym.panfineri, sym.panfinel const colNote = 239 const colInst = 114 +const colInstMetaStray = 205 // inst drawn red when it's a stray meta-layer child (use the meta instead) const colVol = 155 const colPan = 219 const colEffOp = 220 const colEffArg = 231 const colBackPtn = 255 +// Cached 256-flag array (from taut_views.buildMetaLayerChildSlots): inst slots that are a +// non-meta layer child of a Metainstrument. Rebuilt lazily; invalidated on song switch and +// whenever a pattern panel is (re)entered (instruments may have changed in the Instrmnt tab). +let metaLayerFlags = null +function invalidateMetaLayerFlags() { metaLayerFlags = null } +function instColour(inst) { + if (metaLayerFlags === null && HUB.views && HUB.views.buildMetaLayerChildSlots) + metaLayerFlags = HUB.views.buildMetaLayerChildSlots() + return (inst && metaLayerFlags && metaLayerFlags[inst]) ? colInstMetaStray : colInst +} + const PITCH_PRESET_IDX_DEFAULT = 120 // Seed value used during global init (integrity check + first rebuildPitchLut); // the open/switch paths override it per-song from sMet via applySongPitchPreset(). @@ -718,7 +730,7 @@ function buildRowCell(ptnDat, row) { } return { sNote, sInst, sVolEff, sVolArg, sPanEff, sPanArg, sEffOp, sEffArg, - _note: note, _effop: effop, _effarg: effarg, _voleff: voleff, _paneff: paneff } + _note: note, _inst: inst, _effop: effop, _effarg: effarg, _voleff: voleff, _paneff: paneff } } const EMPTY_CELL = { @@ -730,13 +742,13 @@ const EMPTY_CELL = { sPanArg: sym.middot.repeat(2), sEffOp: sym.middot, sEffArg: sym.middot.repeat(4), - _note: 0x0000, _effop: 0, _effarg: 0, _voleff: 0, _paneff: 0 + _note: 0x0000, _inst: 0, _effop: 0, _effarg: 0, _voleff: 0, _paneff: 0 } function drawCellAt(y, x, cell, back) { con.move(y, x) con.color_pair(colNote, back); print(cell.sNote) - con.color_pair(colInst, back); print(cell.sInst) + con.color_pair(instColour(cell._inst), back); print(cell.sInst) con.color_pair(colVol, back); print(cell.sVolEff) con.color_pair(colVol, back); print(cell.sVolArg) con.color_pair(colPan, back); print(cell.sPanEff) @@ -755,7 +767,7 @@ function drawCellAtStyled(y, x, cell, back, style) { con.move(y, x) con.color_pair(colNote, back); print(cell.sNote) con.color_pair(colBackPtn, back); print(' ') - con.color_pair(colInst, back); print(cell.sInst) + con.color_pair(instColour(cell._inst), back); print(cell.sInst) con.color_pair(colBackPtn, back); print(' ') con.color_pair(colVol, back); print(cell.sVolEff); print(cell.sVolArg) con.color_pair(colBackPtn, back); print(' ') @@ -1085,6 +1097,7 @@ const VIEW_FILE = 6 const colPlayback = 86 const colHighlight = 41 +const colEditHL = 86 // sub-field cursor background while in pattern edit mode (red = editing) const colColumnSep = 6 const colRowNum = 250 const colRowNumEmph1 = 225 @@ -1225,12 +1238,38 @@ function drawStatusBar() { con.color_pair(colWHITE, 255); print(` Row `) con.color_pair(130, 255); print(`${sRow}${beatInd}`) + // View/Edit mode badge (Timeline + Patterns panels only) + if (currentPanel === VIEW_TIMELINE || currentPanel === VIEW_PATTERN_DETAILS) { + con.move(1, 22) + if (patternEditMode) { con.color_pair(colWHITE, colEditHL); print(' EDIT ') } + else { con.color_pair(235, 255); print(' VIEW ') } + } + + if (!patternEditMode) { + } + + // Edit-mode info strip (right of the centred logo): the current jam instrument and + // octave. Only shown while editing on a pattern panel; blank in view mode. Drawn only + // if it fits between the logo and the transport buttons (rightmost is ~col SCRW-18). + if ((currentPanel === VIEW_TIMELINE || currentPanel === VIEW_PATTERN_DETAILS) && patternEditMode) { + // editOctave is the period index; the pattern shows octave digits as (period - 1) + // in hex, so display the same so a jammed root key matches its cell's octave. + const octShown = (editOctave - 1).toString(16) + const stripX = 4 + con.move(2, stripX) + con.color_pair(colWHITE, 255); print('Inst ') + con.color_pair(colInst, 255); print(currentInstrument.hex02()) + con.color_pair(colWHITE, 255); print(' Oct ') + con.color_pair(235, 255); print(octShown) + } // bpm spd - con.move(2,4) - con.color_pair(colWHITE, 255); print(`BPM `) - con.color_pair(161, 255); print(`${sBPM}`) - con.color_pair(colWHITE, 255); print(` Tick `) - con.color_pair(235, 255); print(`${sSpd}`) + else { + con.move(2, 4) + con.color_pair(colWHITE, 255); print(`BPM `) + con.color_pair(161, 255); print(`${sBPM}`) + con.color_pair(colWHITE, 255); print(` Tick `) + con.color_pair(235, 255); print(`${sSpd}`) + } // app title gl.drawTexImageOver(logoTexture, (SCRPW-logoTexture.width) >>> 1, 7) @@ -1462,8 +1501,9 @@ function drawPatternRowAt(viewRow, style = timelineRowStyle) { if (style === 0 && highlight && playbackMode === PLAYMODE_NONE && voice === cursorVox) { const fieldStr = [cell.sNote, cell.sInst, cell.sVolEff+cell.sVolArg, cell.sPanEff+cell.sPanArg, cell.sEffOp, cell.sEffArg][timelineColCursor] + const ovFg = (timelineColCursor === 1) ? instColour(cell._inst) : TL_FIELD_FGS[timelineColCursor] con.move(y, x + TL_FIELD_OFFSETS[timelineColCursor]) - con.color_pair(TL_FIELD_FGS[timelineColCursor], colPlayback) + con.color_pair(ovFg, patternEditMode ? colEditHL : colPlayback) print(fieldStr) } } @@ -1946,8 +1986,9 @@ function drawVoiceColumnAt(slot) { if (timelineRowStyle === 0 && highlight && playbackMode === PLAYMODE_NONE && voice === cursorVox) { const fieldStr = [cell.sNote, cell.sInst, cell.sVolEff+cell.sVolArg, cell.sPanEff+cell.sPanArg, cell.sEffOp, cell.sEffArg][timelineColCursor] + const ovFg = (timelineColCursor === 1) ? instColour(cell._inst) : TL_FIELD_FGS[timelineColCursor] con.move(y, x + TL_FIELD_OFFSETS[timelineColCursor]) - con.color_pair(TL_FIELD_FGS[timelineColCursor], colPlayback) + con.color_pair(ovFg, patternEditMode ? colEditHL : colPlayback) print(fieldStr) } } @@ -1993,6 +2034,15 @@ let patternGridCol = 0 let simState = null let simStateKey = '' +// ── Pattern-cell editing (Timeline + Patterns share one cell editor) ── +// patternEditMode is the View/Edit toggle (space bar); shared by both panels. +// currentInstrument is stamped onto jammed notes and auto-adopted when the cursor +// lands on a populated cell or the user types into the inst column. +// editOctave is the period index used as the base for white/black-snapped jamming. +let patternEditMode = false +let currentInstrument = 1 +let editOctave = ANCHOR_PERIOD + if (exec_args[1] === undefined) { println(`Usage: ${exec_args[0]} path_to.taud`) return 1 @@ -2067,6 +2117,7 @@ function switchSong(newIndex) { currentSongIndex = newIndex song = loadTaud(fullPathObj.full, newIndex) refreshSamplesCache() + invalidateMetaLayerFlags() applySongPitchPreset(songsMeta.songs[newIndex]) applySongBeatDiv(songsMeta.songs[newIndex]) @@ -2273,6 +2324,7 @@ function cueInstToStr(inst) { function timelineInput(wo, event) { const keysym = event[1] + const sc = event[3] // primary physical scancode (layout-independent) const keyJustHit = (1 == event[2]) const shiftDown = (event.includes(59) || event.includes(60)) const moveDelta = shiftDown ? 4 : 1 @@ -2281,7 +2333,12 @@ function timelineInput(wo, event) { if (keyJustHit && shiftDown && event.includes(keys.E)) { setTimelineRowStyle(1); return } if (keyJustHit && shiftDown && event.includes(keys.R)) { setTimelineRowStyle(2); return } - if (keyJustHit && (keysym === '[' || keysym === ']')) { nudgeTickRate(keysym === '[' ? -1 : 1); return } + // [ / ] nudges the tick rate, EXCEPT in edit mode on the note column where they + // lower/raise the note by one unit (handled by the cell editor below). + if (keyJustHit && !shiftDown && (sc === keys.LEFT_BRACKET || sc === keys.RIGHT_BRACKET) && + !(patternEditMode && playbackMode === PLAYMODE_NONE && timelineColCursor === 0)) { + nudgeTickRate(sc === keys.LEFT_BRACKET ? -1 : 1); return + } if (playbackMode !== PLAYMODE_NONE) { if (keyJustHit && shiftDown && event.includes(keys.Y) || keysym === " ") { stopPlayback(); redrawPanel(); drawAlwaysOnElems() } @@ -2303,7 +2360,43 @@ function timelineInput(wo, event) { if (keyJustHit && shiftDown && event.includes(keys.Y)) { startPlaySong(); redrawPanel(); return } if (keyJustHit && shiftDown && event.includes(keys.U)) { startPlayCue(); redrawPanel(); return } if ( shiftDown && event.includes(keys.I)) { startPlayRow(); drawPatternRowAt(cursorRow - scrollRow); return } - if (keyJustHit && shiftDown && event.includes(keys.O) || keysym === " ") { stopPlayback(); drawAlwaysOnElems(); return } + if (keyJustHit && shiftDown && event.includes(keys.O)) { stopPlayback(); drawAlwaysOnElems(); return } + // Space toggles View/Edit while stopped (the playing branch above already stops on space). + if (keysym === " ") { if (keyJustHit) toggleEditMode(); return } + + // ── Edit mode: insert/jam into the current cell (discrete, on key-down only); + // View mode: audition jam keys. Navigation keys fall through to the cursor logic. ── + // Cell editing needs the detailed timeline (style 0) where the sub-field cursor exists. + if (patternEditMode && keyJustHit && timelineRowStyle === 0) { + const cue = song.cues[cueIdx] + const ptnIdx = cue ? cue.ptns[cursorVox] : CUE_EMPTY + if (ptnIdx !== CUE_EMPTY && ptnIdx < song.numPats) { + const ptnDat = song.patterns[ptnIdx] + const res = editPatternCell(ptnDat, cursorRow, timelineColCursor, event, noteFieldScreenPos()) + if (res.changed) { + simStateKey = '' + if (res.audition >= 0 && typeof audio.jamNote === 'function') + audio.jamNote(PLAYHEAD, cursorVox, res.audition, currentInstrument) + drawPatternRowAt(cursorRow - scrollRow) + if (res.advance) { + const oc = cursorRow, os = scrollRow + cursorRow = Math.min(ROWS_PER_PAT - 1, cursorRow + 1) + clampCursor() + if (scrollRow === os) drawPatternRowAt(oc - scrollRow) + else drawPatternView() + drawPatternRowAt(cursorRow - scrollRow) + drawSeparators(separatorStyle) + } + drawAlwaysOnElems() + return + } + if (res.octave) { drawAlwaysOnElems(); return } // octave-only change: refresh the indicator + } + } else if (!patternEditMode && keyJustHit && !shiftDown && jamScancodeToSemitone(sc) !== null) { + const n = semitoneToNote(jamScancodeToSemitone(sc), editOctave) + if (n !== null && typeof audio.jamNote === 'function') audio.jamNote(PLAYHEAD, cursorVox, n, currentInstrument) + return + } const oldCursor = cursorRow const oldScroll = scrollRow @@ -2325,6 +2418,7 @@ function timelineInput(wo, event) { } clampVoice() if (triedCross && cursorVox === prevVox) timelineColCursor = dir < 0 ? 0 : 5 + if (patternEditMode) { const c = currentEditCell(); if (c) adoptInstrumentFromCell(c.ptnDat, c.row) } const dVoice = voiceOff - oldVoiceOff if (dVoice !== 0) { shiftPatternAreaHorizontal(dVoice); drawVoiceColumnAt(dVoice > 0 ? VOCSIZE_TIMELINE_FULL - 1 : 0) } drawVoiceHeaders(); drawSeparators(separatorStyle); drawAlwaysOnElems(); drawVoiceDetail() @@ -2332,8 +2426,9 @@ function timelineInput(wo, event) { return } - if (keyJustHit && !shiftDown && event.includes(keys.M)) { toggleMute(cursorVox); return } - if (keyJustHit && !shiftDown && event.includes(keys.N)) { toggleSolo(cursorVox); return } + // Mute / solo are View-mode only (not in the documented EDIT MODE controls). + if (!patternEditMode && keyJustHit && !shiftDown && event.includes(keys.M)) { toggleMute(cursorVox); return } + if (!patternEditMode && keyJustHit && !shiftDown && event.includes(keys.N)) { toggleSolo(cursorVox); return } if (keysym === "") { cursorRow -= moveDelta; rowMove = true } else if (keysym === "") { cursorRow += moveDelta; rowMove = true } @@ -2344,6 +2439,7 @@ function timelineInput(wo, event) { else return clampCursor(); clampVoice(); clampCue() + if (patternEditMode) { const c = currentEditCell(); if (c) adoptInstrumentFromCell(c.ptnDat, c.row) } if (fullRedraw) { drawAll(); return } if (!rowMove || cursorRow === oldCursor) return @@ -2441,6 +2537,264 @@ function ordersInput(wo, event) { drawAlwaysOnElems() } +///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// PATTERN CELL EDITOR (shared by Timeline + Patterns panels) +///////////////////////////////////////////////////////////////////////////////////////////////////////////// + +// Special note words (non-pitched). 0 = empty, 1..4 = key-off/cut/fade/fastfade. +// 0x10..0x1F are reserved internal interrupts; pitched notes are >= 0x20. +function noteIsPitched(n) { return n >= 0x0020 } + +// ── The cell editor dispatches on the raw key SCANCODE (event[3] = keys.head()), not the +// layout-resolved keysym, so the piano layout and edit keys keep their physical positions on +// any keyboard layout (QWERTY / Dvorak / Colemak). Shift state is read separately. ── + +// White/black piano layout by physical key: a s d f g h j k = white keys +// (semitone 0 2 4 5 7 9 11 12), w e t y u = black keys (1 3 6 8 10). +const SC_JAM = {} +;[[keys.A,0],[keys.W,1],[keys.S,2],[keys.E,3],[keys.D,4],[keys.F,5],[keys.T,6], + [keys.G,7],[keys.Y,8],[keys.H,9],[keys.U,10],[keys.J,11],[keys.K,12]].forEach(p => SC_JAM[p[0]] = p[1]) +function jamScancodeToSemitone(sc) { + return Object.prototype.hasOwnProperty.call(SC_JAM, sc) ? SC_JAM[sc] : null +} + +// Scancode → digit (0..9) and scancode → letter index (a=0..z=25), built from the keysym +// table so they don't assume a contiguous scancode range. +const SC_DIGIT = {} +for (let d = 0; d <= 9; d++) SC_DIGIT[keys['NUM_' + d]] = d +const SC_LETTER = {} +;('ABCDEFGHIJKLMNOPQRSTUVWXYZ').split('').forEach((c, i) => { SC_LETTER[keys[c]] = i }) +// hex nibble (0..15) from a digit / a..f scancode, else -1 +function scToHexNibble(sc) { + if (Object.prototype.hasOwnProperty.call(SC_DIGIT, sc)) return SC_DIGIT[sc] + if (Object.prototype.hasOwnProperty.call(SC_LETTER, sc) && SC_LETTER[sc] < 6) return 10 + SC_LETTER[sc] + return -1 +} +// base-36 value (0..35) from a digit / a..z scancode, else -1 (effect-op column) +function scToBase36(sc) { + if (Object.prototype.hasOwnProperty.call(SC_DIGIT, sc)) return SC_DIGIT[sc] + if (Object.prototype.hasOwnProperty.call(SC_LETTER, sc)) return 10 + SC_LETTER[sc] + return -1 +} + +// Map a 12-EDO semitone to a note word in the active tuning by snapping the semitone's +// fractional period position to the NEAREST entry of the preset's pitch table (white/black +// snapped). Returns null for the Raw preset (no table) \u2014 jamming is then disabled. +function semitoneToNote(semi, period) { + const preset = pitchTablePresets[PITCH_PRESET_IDX] + if (!preset || preset.table.length === 0) return null + const interval = preset.interval + const table = preset.table + let pos = Math.round(semi / 12 * interval) + let carry = 0 + while (pos >= interval) { pos -= interval; carry++ } // semitone 12 wraps to next period root + let bestIdx = 0, bestDist = Infinity + for (let i = 0; i < table.length; i++) { + const d = Math.abs(table[i] - pos) + if (d < bestDist) { bestDist = d; bestIdx = i } + } + // The next period's root (interval above) can be the true nearest degree near the top. + let off = table[bestIdx], periodAdj = carry + if ((interval - pos) < bestDist) { off = table[0]; periodAdj = carry + 1 } + // Clamp into the playable note range (>= 0x20; below that are the special-note words). + return Math.max(0x0020, Math.min(0xFFFF, composeNote(period + periodAdj, off, interval))) +} + +// Shift a pitched note to the adjacent pitch-table degree (period-wrapping). No-op on +// special notes. Raw preset: \u00b11 raw unit. +function nudgeNoteUnit(note, dir) { + if (!noteIsPitched(note)) return note + const preset = pitchTablePresets[PITCH_PRESET_IDX] + if (!preset || preset.table.length === 0) return Math.max(0x20, Math.min(0xFFFF, note + dir)) + const interval = preset.interval, table = preset.table + const [period, off] = decomposeNote(note, interval) + let idx = 0, best = Infinity + for (let i = 0; i < table.length; i++) { const d = Math.abs(table[i] - off); if (d < best) { best = d; idx = i } } + idx += dir + let per = period + if (idx < 0) { idx = table.length - 1; per -= 1 } + else if (idx >= table.length){ idx = 0; per += 1 } + return Math.max(0x20, Math.min(0xFFFF, composeNote(per, table[idx], interval))) +} + +// Shift a pitched note by one period (octave). No-op on special notes. +function nudgeNoteOctave(note, dir) { + if (!noteIsPitched(note)) return note + const preset = pitchTablePresets[PITCH_PRESET_IDX] + const interval = (preset && preset.table.length) ? preset.interval : 0x1000 + return Math.max(0x20, Math.min(0xFFFF, note + dir * interval)) +} + +// \u2500\u2500 cell byte accessors (ptnDat = the 512-byte Uint8Array, row 0..63) \u2500\u2500 +function cellNote(p, r) { const o = 8*r; return p[o] | (p[o+1] << 8) } +function writeNote(p, r, n) { const o = 8*r; p[o] = n & 0xFF; p[o+1] = (n >>> 8) & 0xFF } +function cellInst(p, r) { return p[8*r + 2] } +function writeInst(p, r, v) { p[8*r + 2] = v & 0xFF } + +// Edit a vol/pan byte from one keystroke (by scancode + shift). Selector = top 2 bits +// (0=SET, 1=up/right, 2=down/left, 3=fine; fine dir bit 0x20). Empty sentinel = 0xC0. +// Physical keys: ^ = Shift+6 (vol slide up), v = V key (vol slide down), < / > = Shift+,/. +// (pan slide left/right), - / = (fine down/up), . = clear, Backspace = drop a digit. +function editVolPanByte(byte, sc, shiftDown, isPan) { + const SEL_SET = 0, SEL_UP = 1, SEL_DOWN = 2, SEL_FINE = 3 + let sel = (byte >>> 6) & 3, arg = byte & 0x3F + const empty = (sel === SEL_FINE && arg === 0) // 0xC0 + + if (sc === keys.PERIOD && !shiftDown) return 0xC0 + if (sc === keys.BACKSPACE) { + if (empty) return 0xC0 + if (sel === SEL_SET) return arg >>> 4 // shift a set-vol digit out + const m = (arg & 0x1F) >>> 4 // shift a slide/fine digit out + if (m === 0) return 0xC0 + return ((sel & 3) << 6) | (arg & 0x20) | (m & 0x1F) + } + // selector keys (checked before SET digits so Shift+6 is '^', not the digit 6) + const lowArg = arg & 0x0F + if (!isPan && shiftDown && sc === keys.NUM_6) return (SEL_UP << 6) | lowArg // ^ + if (!isPan && !shiftDown && sc === keys.V) return (SEL_DOWN << 6) | lowArg // v + if ( isPan && shiftDown && sc === keys.PERIOD) return (SEL_UP << 6) | lowArg // > (pan slide right) + if ( isPan && shiftDown && sc === keys.COMMA) return (SEL_DOWN << 6) | lowArg // < (pan slide left) + if (!shiftDown && sc === keys.MINUS) return (SEL_FINE << 6) | Math.max(1, arg & 0x1F) // - fine down + if (!shiftDown && sc === keys.EQUALS) return (SEL_FINE << 6) | 0x20 | Math.max(1, arg & 0x1F) // = fine up + // hex digit edits the argument in the current selector's width + if (!shiftDown) { + const nib = scToHexNibble(sc) + if (nib >= 0) { + if (sel === SEL_SET || empty) return ((((empty ? 0 : arg) << 4) | nib) & 0x3F) // SET, 2 digits + if (sel === SEL_UP || sel === SEL_DOWN) return (sel << 6) | (nib & 0x0F) // slide, 1 digit + return (SEL_FINE << 6) | (arg & 0x20) | (nib & 0x1F) // fine magnitude + } + } + return byte +} + +// The shared cell editor. Mutates ptnDat at (row, col) from key event `ev`. +// `popupPos` = {y, x} of the note field (only used for the 'b' raw-hex popup). +// Returns { changed, advance, audition } \u2014 audition is a note word to jam, or -1. +function editPatternCell(ptnDat, row, col, ev, popupPos) { + let sc = ev[3]; if (sc == 59) sc = ev[4]; if (sc == 60) sc = ev[5]; // sc = first non-shift scancode + if (!sc) return { changed: false, advance: false, audition: -1, octave: false } + const shiftDown = (ev.includes(59) || ev.includes(60)) + const isClear = (sc === keys.PERIOD && !shiftDown) // '.' + const isBack = (sc === keys.BACKSPACE) + const o = 8 * row + let changed = false, advance = false, audition = -1, octave = false + + if (col === 0) { // \u2500\u2500 note \u2500\u2500 + const semi = (!shiftDown) ? jamScancodeToSemitone(sc) : null + if (semi !== null) { + const n = semitoneToNote(semi, editOctave) + if (n !== null) { writeNote(ptnDat, row, n); writeInst(ptnDat, row, currentInstrument) + changed = true; advance = true; audition = n } + } + // Special notes (key-off / cut / fade / fast-fade) are inserted but not auditioned + // (jamming a key-off through the trigger path would resolve a bogus sample). + else if (!shiftDown && sc === keys.Z) { writeNote(ptnDat, row, 0x0001); changed = true; advance = true } + else if (!shiftDown && sc === keys.X) { writeNote(ptnDat, row, 0x0002); changed = true; advance = true } + else if (!shiftDown && sc === keys.C) { writeNote(ptnDat, row, 0x0003); changed = true; advance = true } + else if (!shiftDown && sc === keys.V) { writeNote(ptnDat, row, 0x0004); changed = true; advance = true } + else if (!shiftDown && sc === keys.B && popupPos) { + const raw = openInlineHexEdit(popupPos.y, popupPos.x, 4, cellNote(ptnDat, row)) + if (raw !== null) { const w = raw & 0xFFFF; writeNote(ptnDat, row, w); changed = true; advance = true; if (noteIsPitched(w)) audition = w } + } + // [ / ] unit nudge (no shift); { / } octave nudge (Shift+[ / Shift+]) + else if (!shiftDown && (sc === keys.LEFT_BRACKET || sc === keys.RIGHT_BRACKET)) { + const cur = cellNote(ptnDat, row) + if (noteIsPitched(cur)) { const n = nudgeNoteUnit(cur, sc === keys.LEFT_BRACKET ? -1 : 1); writeNote(ptnDat, row, n); changed = true; audition = n } + } + else if (shiftDown && (sc === keys.LEFT_BRACKET || sc === keys.RIGHT_BRACKET)) { + const dir = (sc === keys.LEFT_BRACKET) ? -1 : 1 + const cur = cellNote(ptnDat, row) + if (noteIsPitched(cur)) { const n = nudgeNoteOctave(cur, dir); writeNote(ptnDat, row, n); editOctave = decomposeNote(n, pitchTablePresets[PITCH_PRESET_IDX].interval)[0]; changed = true; audition = n } + // No-sound cell (< 0x20): nothing to transpose, so move the jam octave instead + // (octave-only — refreshes the indicator without dirtying the pattern). + else { editOctave = Math.max(1, Math.min(14, editOctave + dir)); octave = true } + } + else if (isClear || isBack) { writeNote(ptnDat, row, 0); changed = true } + } + else if (col === 1) { // \u2500\u2500 instrument \u2500\u2500 + const nib = (!shiftDown) ? scToHexNibble(sc) : -1 + if (nib >= 0) { + const v = ((cellInst(ptnDat, row) << 4) | nib) & 0xFF + writeInst(ptnDat, row, v); currentInstrument = v; changed = true + } + else if (isBack) { writeInst(ptnDat, row, cellInst(ptnDat, row) >>> 4); changed = true } + else if (isClear) { writeInst(ptnDat, row, 0); changed = true } + } + else if (col === 2 || col === 3) { // \u2500\u2500 volume / panning \u2500\u2500 + const off = (col === 2) ? o + 3 : o + 4 + const nb = editVolPanByte(ptnDat[off], sc, shiftDown, col === 3) + if (nb !== ptnDat[off]) { ptnDat[off] = nb & 0xFF; changed = true } + } + else if (col === 4) { // \u2500\u2500 effect op \u2500\u2500 + const v = (!shiftDown) ? scToBase36(sc) : -1 + if (v >= 0) { ptnDat[o+5] = v & 0xFF; changed = true } + else if (isClear || isBack) { ptnDat[o+5] = 0; changed = true } + } + else if (col === 5) { // \u2500\u2500 effect arg (16-bit) \u2500\u2500 + const cur = ptnDat[o+6] | (ptnDat[o+7] << 8) + const nib = (!shiftDown) ? scToHexNibble(sc) : -1 + if (nib >= 0) { + const v = ((cur << 4) | nib) & 0xFFFF + ptnDat[o+6] = v & 0xFF; ptnDat[o+7] = (v >>> 8) & 0xFF; changed = true + } + else if (isBack) { const v = cur >>> 4; ptnDat[o+6] = v & 0xFF; ptnDat[o+7] = (v >>> 8) & 0xFF; changed = true } + else if (isClear) { ptnDat[o+6] = 0; ptnDat[o+7] = 0; changed = true } + } + + if (changed) { patternsOutOfSync = true; if (HUB && HUB.markUnsaved) HUB.markUnsaved() } + return { changed, advance, audition, octave } +} + +// Adopt the instrument under a cell as the current instrument, if it carries one. +function adoptInstrumentFromCell(ptnDat, row) { + const inst = cellInst(ptnDat, row) + if (inst !== 0) currentInstrument = inst +} + +// Resolve the cell currently under the edit cursor for whichever pattern panel is active. +// Returns { ptnDat, row, col, voice } or null (e.g. a timeline voice with no pattern). +function currentEditCell() { + if (currentPanel === VIEW_TIMELINE) { + const cue = song.cues[cueIdx] + const pi = cue ? cue.ptns[cursorVox] : CUE_EMPTY + if (pi !== CUE_EMPTY && pi < song.numPats) + return { ptnDat: song.patterns[pi], row: cursorRow, col: timelineColCursor, voice: cursorVox } + } else if (currentPanel === VIEW_PATTERN_DETAILS) { + if (song.numPats > 0) + return { ptnDat: song.patterns[patternIdx], row: patternGridRow, col: patternGridCol, voice: 0 } + } + return null +} + +// Screen position {y, x} of the note field under the edit cursor (for the 'b' raw-hex popup). +function noteFieldScreenPos() { + if (currentPanel === VIEW_TIMELINE) { + if (timelineRowStyle !== 0) return null // sub-field cursor only exists in the detailed style + const x = PTNVIEW_OFFSET_X + COLSIZE_TIMELINE_FULL * (cursorVox - voiceOff) + TL_FIELD_OFFSETS[0] + const y = PTNVIEW_OFFSET_Y + (cursorRow - scrollRow) + return { y, x } + } else if (currentPanel === VIEW_PATTERN_DETAILS) { + return { y: PTNVIEW_OFFSET_Y + (patternGridRow - patternGridScroll), x: PATEDITOR_CELL_X } + } + return null +} + +// Toggle View/Edit mode (shared by both pattern panels). On entering edit, seed the current +// instrument from the Instruments-tab selection, then auto-adopt the cell under the cursor. +// On leaving, silence any lingering audition. +function toggleEditMode() { + patternEditMode = !patternEditMode + if (patternEditMode) { + const seed = (HUB.views && HUB.views.getSelectedInstrumentSlot && HUB.views.getSelectedInstrumentSlot()) || currentInstrument + currentInstrument = seed || 1 + const c = currentEditCell(); if (c) adoptInstrumentFromCell(c.ptnDat, c.row) + } else if (typeof audio.jamStop === 'function') { + audio.jamStop(PLAYHEAD) + } + redrawPanel(); drawAlwaysOnElems() +} + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // PATTERN EDITOR PANEL ///////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -2851,10 +3205,10 @@ function drawPatternGridRowAt(viewRow) { cell.sEffOp, cell.sEffArg, ] - const fieldFgs = [colNote, colInst, colVol, colPan, colEffOp, colEffArg] + const fieldFgs = [colNote, instColour(cell._inst), colVol, colPan, colEffOp, colEffArg] const col = patternGridCol con.move(y, PATEDITOR_CELL_X + fieldOffsets[col]) - con.color_pair(fieldFgs[col], colHighlight) + con.color_pair(fieldFgs[col], patternEditMode ? colEditHL : colHighlight) print(fieldStrs[col]) } } @@ -2903,11 +3257,16 @@ function drawPatternsContents(wo) { function patternsInput(wo, event) { const keysym = event[1] + const sc = event[3] // primary physical scancode (layout-independent) const keyJustHit = (1 == event[2]) const shiftDown = (event.includes(59) || event.includes(60)) const moveDelta = shiftDown ? 4 : 1 - if (keyJustHit && (keysym === '[' || keysym === ']')) { nudgeTickRate(keysym === '[' ? -1 : 1); return } + // [ / ] nudges tick rate, except in edit mode on the note column (unit nudge below). + if (keyJustHit && !shiftDown && (sc === keys.LEFT_BRACKET || sc === keys.RIGHT_BRACKET) && + !(patternEditMode && playbackMode === PLAYMODE_NONE && patternGridCol === 0)) { + nudgeTickRate(sc === keys.LEFT_BRACKET ? -1 : 1); return + } if (playbackMode !== PLAYMODE_NONE) { if ((keyJustHit && shiftDown && event.includes(keys.Y)) || keysym === " ") { @@ -2918,13 +3277,36 @@ function patternsInput(wo, event) { if (keyJustHit && shiftDown && event.includes(keys.U)) { startPlayPattern(); drawPatternsContents(wo); return } if ( shiftDown && event.includes(keys.I)) { startPlayPatternRow(); drawPatternGrid(); return } - if ((keyJustHit && shiftDown && event.includes(keys.O)) || keysym === " ") { stopPlayback(); drawAlwaysOnElems(); return } + if (keyJustHit && shiftDown && event.includes(keys.O)) { stopPlayback(); drawAlwaysOnElems(); return } + // Space toggles View/Edit while stopped. + if (keysym === " ") { if (keyJustHit) toggleEditMode(); return } if (song.numPats === 0) return + // ── Edit mode: insert/jam into the current cell (discrete); View mode: audition. ── + if (patternEditMode && keyJustHit) { + const ptnDat = song.patterns[patternIdx] + const res = editPatternCell(ptnDat, patternGridRow, patternGridCol, event, noteFieldScreenPos()) + if (res.changed) { + if (res.audition >= 0 && typeof audio.jamNote === 'function') + audio.jamNote(PLAYHEAD, 0, res.audition, currentInstrument) + if (res.advance) { patternGridRow = Math.min(ROWS_PER_PAT - 1, patternGridRow + 1); clampPatternGrid() } + simStateKey = '' + drawPatternsContents(wo) + drawAlwaysOnElems() + return + } + if (res.octave) { drawAlwaysOnElems(); return } // octave-only change: refresh the indicator + } else if (!patternEditMode && keyJustHit && !shiftDown && jamScancodeToSemitone(sc) !== null) { + const n = semitoneToNote(jamScancodeToSemitone(sc), editOctave) + if (n !== null && typeof audio.jamNote === 'function') audio.jamNote(PLAYHEAD, 0, n, currentInstrument) + return + } + if (keysym === '' || keysym === '') { patternGridRow += (keysym === '') ? -moveDelta : moveDelta clampPatternGrid() + if (patternEditMode) { adoptInstrumentFromCell(song.patterns[patternIdx], patternGridRow); drawAlwaysOnElems() } simStateKey = '' drawPatternGrid() con.color_pair(colSep, 255) @@ -2940,8 +3322,8 @@ function patternsInput(wo, event) { return } - if (keysym === '') { patternGridRow = 0; clampPatternGrid(); simStateKey = ''; drawPatternsContents(wo); return } - if (keysym === '') { patternGridRow = ROWS_PER_PAT-1; clampPatternGrid(); simStateKey = ''; drawPatternsContents(wo); return } + if (keysym === '') { patternGridRow = 0; clampPatternGrid(); if (patternEditMode) { adoptInstrumentFromCell(song.patterns[patternIdx], patternGridRow); drawAlwaysOnElems() } simStateKey = ''; drawPatternsContents(wo); return } + if (keysym === '') { patternGridRow = ROWS_PER_PAT-1; clampPatternGrid(); if (patternEditMode) { adoptInstrumentFromCell(song.patterns[patternIdx], patternGridRow); drawAlwaysOnElems() } simStateKey = ''; drawPatternsContents(wo); return } if (keysym === '' || keysym === '') { patternGridCol += (keysym === '') ? -1 : 1 @@ -2956,6 +3338,7 @@ function patternsInput(wo, event) { if (keysym === '' || keysym === '') { patternIdx += (keysym === '') ? -moveDelta : moveDelta clampPatternIdx() + if (patternEditMode) { adoptInstrumentFromCell(song.patterns[patternIdx], patternGridRow); drawAlwaysOnElems() } simStateKey = '' drawPatternsContents(wo) return @@ -3622,6 +4005,7 @@ HUB.getPlaybackMode = () => playbackMode HUB.markUnsaved = () => { hasUnsavedChanges = true } // In-process editor modals (openSampleEdit / openAdvancedInstEdit) call this each // frame to keep playback + blobs alive while open — the whole point of going +// frame to keep playback + blobs alive while open — the whole point of going // in-process (the old separate programs called stopPlayback on entry). HUB.tickPlayback = () => { if (playbackMode !== PLAYMODE_NONE) updatePlayback() } HUB.stopPlayback = stopPlayback @@ -3920,6 +4304,8 @@ function addGlobalMouseRegion(x, y, w, h, handlers) { MOUSE_GLOBAL.push(Object.a // Apply the same panel-switch logic the Tab key path uses. function switchToPanel(newPanel) { if (newPanel === currentPanel) return + if (typeof audio.jamStop === 'function') audio.jamStop(PLAYHEAD) // silence any lingering jam audition + invalidateMetaLayerFlags() // instruments may have changed const wasTimeline = (currentPanel === VIEW_TIMELINE) const wasSamples = (currentPanel === VIEW_SAMPLES) const wasInstrmnt = (currentPanel === VIEW_INSTRMNT) @@ -4234,6 +4620,8 @@ while (!exitFlag) { } if (keyJustHit && keysym === "") { + if (typeof audio.jamStop === 'function') audio.jamStop(PLAYHEAD) // silence any lingering jam audition + invalidateMetaLayerFlags() // instruments may have changed const wasTimeline = (currentPanel === VIEW_TIMELINE) const wasSamples = (currentPanel === VIEW_SAMPLES) currentPanel = (currentPanel + (shiftDown ? -1 : 1)) diff --git a/assets/disk0/tvdos/bin/taut_helpmsg.mjs b/assets/disk0/tvdos/bin/taut_helpmsg.mjs index 6b8fa07..567762f 100644 --- a/assets/disk0/tvdos/bin/taut_helpmsg.mjs +++ b/assets/disk0/tvdos/bin/taut_helpmsg.mjs @@ -117,6 +117,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using &bul;x : (note column) inserts a note-cut ¬ecutsym; &bul;c : (note column) inserts a note fade ¬efadesym; &bul;v : (note column) inserts a fast fade ¬efastfadesym; +&bul;b : (note column) inserts a note by raw hexadecimal (popup) &bul;. : clears fields &bul;bksp : deletes one character on the selected column &bul;0&ddot;9 a&ddot;f : inserts a (hexa)decimal number diff --git a/assets/disk0/tvdos/bin/taut_views.mjs b/assets/disk0/tvdos/bin/taut_views.mjs index 5ebd461..64b0d64 100644 --- a/assets/disk0/tvdos/bin/taut_views.mjs +++ b/assets/disk0/tvdos/bin/taut_views.mjs @@ -59,6 +59,33 @@ function readInstRecord(slot) { return rec } +// Build a 256-entry flag array where flag[slot] = 1 when `slot` is a NON-meta instrument +// that is referenced as a layer child of some Metainstrument — i.e. an individual layer the +// user punched into a pattern directly when they should have used the meta instrument. +// Reads only the meta marker (bytes 2/3 == 0xFFFF) + each layer's instIdx (offsets 4,14,24..). +function buildMetaLayerChildSlots() { + const memBase = audio.getMemAddr() + const isMeta = new Uint8Array(TAUT_INST_COUNT) + const child = new Uint8Array(TAUT_INST_COUNT) + for (let slot = 1; slot < TAUT_INST_COUNT; slot++) { + const base = TAUT_INST_WINDOW_OFF + slot * TAUT_INST_RECORD_SIZE + if ((sys.peek(memBase - (base + 2)) & 0xFF) === 0xFF && + (sys.peek(memBase - (base + 3)) & 0xFF) === 0xFF) { + isMeta[slot] = 1 + const count = sys.peek(memBase - (base + 1)) & 0xFF + let o = 4 + for (let i = 0; i < count && o + 10 <= TAUT_INST_RECORD_SIZE; i++, o += 10) { + const idx = sys.peek(memBase - (base + o)) & 0xFF + if (idx >= 1) child[idx] = 1 + } + } + } + const flagged = new Uint8Array(TAUT_INST_COUNT) + for (let slot = 1; slot < TAUT_INST_COUNT; slot++) + if (child[slot] && !isMeta[slot]) flagged[slot] = 1 + return flagged +} + // Decode the fields the viewer actually cares about. Offsets from terranmon.txt:2071+. function decodeInstRecord(rec) { const samplePtr = (rec[0]) | (rec[1] << 8) | (rec[2] << 16) | (rec[3] * 0x1000000) @@ -898,6 +925,13 @@ let instListScroll = 0 let instListCursor = 0 let instSubTab = INST_TAB_GEN1 +// The instrument slot currently highlighted in the Instruments panel — used by the pattern +// editor as the seed "current instrument" for note jamming. Falls back to slot 1. +function getSelectedInstrumentSlot() { + const e = instrumentsCache && instrumentsCache[instListCursor] + return (e && e.slot) ? e.slot : 1 +} + // followCursor: see clampSamplesCursor — false = free wheel scroll without moving // the selection. function clampInstrumentsCursor(followCursor = true) { @@ -3186,6 +3220,7 @@ function openAdvancedInstEdit(slot) { drawSamplesUsedBy, computeSampleRAMBytes, formatSampleRamK, launchInstrumentViewerFor, registerInstrumentsMouse, registerSamplesMouse, sampleRamSummary, drawSlider, drawNumCapsule, runSliderDrag, + getSelectedInstrumentSlot, buildMetaLayerChildSlots, } } diff --git a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt index 8937ea5..c1f8ccb 100644 --- a/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt +++ b/tsvm_core/src/net/torvald/tsvm/AudioJSR223Delegate.kt @@ -68,6 +68,26 @@ class AudioJSR223Delegate(private val vm: VM) { fun stop(playhead: Int) { getPlayhead(playhead)?.isPlaying = false } fun isPlaying(playhead: Int) = getPlayhead(playhead)?.isPlaying + /** + * Audition a single note on [voice] of a tracker-mode [playhead] WITHOUT starting song + * playback — the note sounds immediately and its envelope/filter evolve, but rows/cues do + * not advance. Intended for note-jamming in an editor (taut). [note] is the 16-bit pattern + * note word (0x0020..0xFFFF playable; 0x0001 key-off / 0x0002 cut also work), [inst] the + * instrument slot to trigger with. No-op in PCM mode. Stop it with [jamStop]. + */ + fun jamNote(playhead: Int, voice: Int, note: Int, inst: Int) { + val ad = getFirstSnd() ?: return + val ph = getPlayhead(playhead) ?: return + ad.jamNote(ph, voice, note and 0xFFFF, inst and 0xFF) + } + + /** Silence any audition started by [jamNote] on this [playhead]. */ + fun jamStop(playhead: Int) { + val ad = getFirstSnd() ?: return + val ph = getPlayhead(playhead) ?: return + ad.jamStop(ph) + } + /** Lowest-numbered playhead that is not currently playing, so a player app can * "occupy" an idle playhead instead of always clobbering playhead 0. Returns * [fallback] when every playhead is busy (or no audio device is present). */ diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index ce2d075..6e868ce 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -71,8 +71,9 @@ private class RenderRunnable(val playhead: AudioAdapter.Playhead) : Runnable { } } } else { - // Tracker mode - if (playhead.isPlaying) { + // Tracker mode — also render while a jam (audition) note is sounding + // even though the transport is stopped. + if (playhead.isPlaying || playhead.jamActive) { val out = playhead.parent.generateTrackerAudio(playhead) if (out != null) { playhead.audioDevice.writeStereoSamplesUI8(out, 0, AudioAdapter.TRACKER_CHUNK) @@ -4051,7 +4052,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { val out = ByteArray(TRACKER_CHUNK * 2) - if (ts.firstRow) { + // Jam (audition) mode: voices triggered by jamNote are mixed while the song is + // stopped, but WITHOUT advancing rows/cues — only the per-tick voice machinery + // (applyTrackerTick) runs so the jammed note's envelope / filter / fadeout evolve. + // When the song is actually playing, isPlaying takes precedence and the full + // tick/row path runs exactly as before (byte-identical to the pre-jam engine). + val advancing = playhead.isPlaying + + if (advancing && ts.firstRow) { ts.firstRow = false applyTrackerRow(ts, playhead) } @@ -4059,14 +4067,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { for (n in 0 until TRACKER_CHUNK) { // Recompute samples-per-tick every iteration since T/T-slide can mutate BPM mid-row. val spt = SAMPLING_RATE * 2.5 / playhead.bpm - ts.samplesIntoTick += 1.0 - if (ts.samplesIntoTick >= spt) { - ts.samplesIntoTick -= spt - applyTrackerTick(ts, playhead) - ts.tickInRow++ - if (ts.tickInRow >= playhead.tickRate + ts.finePatternDelayExtra) { - ts.tickInRow = 0 - advanceRow(ts, playhead) + if (advancing) { + ts.samplesIntoTick += 1.0 + if (ts.samplesIntoTick >= spt) { + ts.samplesIntoTick -= spt + applyTrackerTick(ts, playhead) + ts.tickInRow++ + if (ts.tickInRow >= playhead.tickRate + ts.finePatternDelayExtra) { + ts.tickInRow = 0 + advanceRow(ts, playhead) + } + } + } else { // jamActive: evolve envelopes only, never advance the song + ts.samplesIntoTick += 1.0 + if (ts.samplesIntoTick >= spt) { + ts.samplesIntoTick -= spt + applyTrackerTick(ts, playhead) } } @@ -4210,9 +4226,38 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { out[n * 2 + 1] = tadDecodedBin[n * 2L + 1] } + // Stop the jam-render spin once the audition has gone fully silent (no foreground + // or background voice left to mix). A real Play press re-arms via isPlaying. + if (playhead.jamActive && !playhead.isPlaying && + ts.voices.none { it.active } && ts.backgroundVoices.none { it.active }) { + playhead.jamActive = false + } + return out } + /** + * Audition one note immediately on [vi] of [ph]'s tracker state, without starting + * song playback (jam mode keeps mixing the voice but never advances rows/cues). + * Reuses the normal trigger path so Ixmp patches / Metainstrument layers resolve. + */ + internal fun jamNote(ph: Playhead, vi: Int, note: Int, inst: Int) { + if (ph.isPcmMode) return + val ts = ph.trackerState ?: return + val v = vi.coerceIn(0, 19) + triggerMetaOrNote(ts, ts.voices[v], v, note, inst, -1) + ph.jamActive = true + } + + /** Silence any running audition and stop the jam-render spin. */ + internal fun jamStop(ph: Playhead) { + ph.trackerState?.let { ts -> + ts.voices.forEach { it.active = false } + ts.backgroundVoices.forEach { it.active = false } + } + ph.jamActive = false + } + /** * Advance to the next row. Resolves pending B/C jumps and pattern-delay repeats. * Called once when [TrackerState.tickInRow] has just wrapped past [Playhead.tickRate]. @@ -4837,6 +4882,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var trackerState: TrackerState? = TrackerState() // default mode is tracker (isPcmMode=false) + // True while a jamNote() audition is sounding with the transport stopped. The render + // thread keeps mixing (envelopes evolve via applyTrackerTick) but rows/cues do NOT + // advance; auto-cleared once every voice goes silent. Ignored while isPlaying. + var jamActive: Boolean = false + // Initial global behaviour flags (song-table byte, written via MMIO register 7 in tracker mode). // Applied to TrackerState on every resetParams(); in-pattern effect '1' can override later. var initialGlobalFlags: Int = 0 @@ -4859,6 +4909,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { pcmQueue.add(ByteArray(audioDevice.bufferSize * audioDevice.bufferCount)) } } + // Starting real playback ends any jam audition: drop the leftover jammed voices + // so a held audition can't bleed into the first rows of the song. + if (!field && value && jamActive) { + trackerState?.let { ts -> + ts.voices.forEach { it.active = false } + ts.backgroundVoices.forEach { it.active = false } + } + jamActive = false + } field = value } @@ -4917,6 +4976,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { position = 0 pcmUploadLength = 0 isPlaying = false + jamActive = false pcmQueueSizeIndex = 2 // Spec §5 defaults — applied on every reset so song-start state is well-defined. bpm = 125