// notepat-native.mjs — Traditional notepat for ac-native bare metal import { PERCUSSION_NAMES, PERCUSSION_LABELS, PERCUSSION_COLORS, PERCUSSION_NOTATION, playPercussion as sharedPlayPercussion, } from "/lib/percussion.mjs"; // setTimeout polyfill — QuickJS on ac-native does not provide setTimeout. // Pending callbacks are queued here and flushed each tick from sim() via // __tickPendingTimeouts(). Delays are in milliseconds and match browser // semantics closely enough for the short audio/UI schedules this piece uses. const __pendingTimeouts = []; function setTimeout(fn, delayMs) { if (typeof fn !== "function") return 0; const at = Date.now() + (Number(delayMs) || 0); __pendingTimeouts.push({ fn, at }); return __pendingTimeouts.length; } function __tickPendingTimeouts() { if (__pendingTimeouts.length === 0) return; const now = Date.now(); for (let i = 0; i < __pendingTimeouts.length; ) { const entry = __pendingTimeouts[i]; if (now >= entry.at) { __pendingTimeouts.splice(i, 1); try { entry.fn(); } catch (e) { console.error("setTimeout cb error:", e); } } else { i++; } } } let sounds = {}; let trail = {}; let activeDrumKeys = {}; // keyboard key -> active percussion hold state let ringingPercussionHolds = []; // percussion holds that should keep updating through their tail let frame = 0; let escCount = 0; let escLastFrame = 0; let fpsLastTime = 0; let fpsDisplay = 0; let fpsAccum = 0; let fpsSamples = 0; let octave = 4; let wave = "sine"; let waveIndex = 0; let quickMode = false; const wavetypes = ["sine", "triangle", "sawtooth", "square", "piano", "harp", "whistle", "sample"]; let sampleLoaded = false; // true when sample buffer has data (default or recorded) let lastLoadedSample = null; // track which sample object is currently loaded let recording = false; // true while holding REC let recPointerId = null; // touch pointer currently holding REC button let recStartTime = 0; // Date.now() when recording started const MAX_REC_SECS = 10; // matches AUDIO_MAX_SAMPLE_SECS const SAMPLE_BASE_FREQ = 261.63; // C4 — fallback base when a sample isn't tonal // Estimate the fundamental frequency (Hz) of a recorded mono sample via // windowed autocorrelation. Sample playback resamples by `freq / base`, so // feeding the note the user *actually* recorded as `base` makes every key // sound at its true note frequency — a chromatic sampler — instead of // assuming the recording was C4. Returns null when the sample is too quiet // or untuned (a drum hit, noise) so callers fall back to SAMPLE_BASE_FREQ. function detectFundamental(data, rate, fMin = 50, fMax = 1500) { if (!data || !rate || data.length < 2048) return null; // Window around the loudest region so silent lead-in doesn't poison the // estimate. Subsample the scan — we only need the rough peak location. const N = Math.min(4096, data.length); const scanStep = Math.max(1, Math.floor(data.length / 2048)); let peakIdx = 0, peakAmp = 0; for (let i = 0; i < data.length; i += scanStep) { const a = data[i] < 0 ? -data[i] : data[i]; if (a > peakAmp) { peakAmp = a; peakIdx = i; } } if (peakAmp < 0.01) return null; // effectively silent let start = peakIdx - (N >> 1); if (start < 0) start = 0; if (start + N > data.length) start = data.length - N; // De-mean + Hann window into a working buffer. const buf = new Float32Array(N); let mean = 0; for (let i = 0; i < N; i++) mean += data[start + i]; mean /= N; let energy0 = 0; for (let i = 0; i < N; i++) { const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (N - 1)); const v = (data[start + i] - mean) * w; buf[i] = v; energy0 += v * v; } if (energy0 <= 1e-9) return null; const minLag = Math.max(2, Math.floor(rate / fMax)); const maxLag = Math.min(N - 1, Math.floor(rate / fMin)); if (maxLag <= minLag) return null; // Normalized autocorrelation; take the first strong local maximum so we // lock onto the fundamental rather than a louder higher harmonic. const nac = new Float32Array(maxLag + 1); for (let lag = minLag; lag <= maxLag; lag++) { let corr = 0; for (let i = 0; i < N - lag; i++) corr += buf[i] * buf[i + lag]; nac[lag] = corr / energy0; } let bestLag = -1, bestVal = 0; for (let lag = minLag + 1; lag < maxLag; lag++) { if (nac[lag] > nac[lag - 1] && nac[lag] >= nac[lag + 1]) { if (nac[lag] > bestVal) { bestVal = nac[lag]; bestLag = lag; } if (bestVal > 0.6) break; // confident enough — first solid peak wins } } if (bestLag < 0 || bestVal < 0.3) return null; // not tonal enough to trust // Parabolic interpolation for sub-sample lag precision. const a = nac[bestLag - 1], b = nac[bestLag], c = nac[bestLag + 1]; const denom = a - 2 * b + c; const shift = denom !== 0 ? (0.5 * (a - c)) / denom : 0; const refinedLag = bestLag + shift; const f0 = rate / refinedLag; return f0 >= fMin && f0 <= fMax ? f0 : null; } // Clamp a sample's playback tone so the resample interval stays within ±1 // octave of its base pitch. speed = tone/base in the engine, so this caps the // ratio at 0.5×–2× — a wrong f0 estimate can shift the timbre but never scream // or rumble off the keyboard. function clampSampleTone(targetFreq, base) { const b = base > 0 ? base : SAMPLE_BASE_FREQ; const ratio = Math.max(0.5, Math.min(2.0, targetFreq / b)); return b * ratio; } // Downsample a recorded buffer to a small peak envelope for cheap per-pad // waveform rendering (computed once at record time, not per frame). function sampleWaveform(data, bins = 20) { if (!data || !data.length) return null; const out = new Array(bins).fill(0); const step = data.length / bins; for (let i = 0; i < bins; i++) { const start = Math.floor(i * step); const end = Math.min(data.length, Math.floor((i + 1) * step)); let peak = 0; for (let j = start; j < end; j++) { const a = data[j] < 0 ? -data[j] : data[j]; if (a > peak) peak = a; } out[i] = peak; } return out; } // Per-key sample bank: End key arms, tone key records to that key only let sampleBank = {}; // key -> { data: Float32Array, len: number, rate: number } let globalSample = null; // { data: Float32Array, len: number, rate: number } — Home recording let endArmed = false; // true while End key is held (arm per-key recording) let perKeyRecording = null; // key currently recording in per-key mode // Hold/latch: Enter works as a hold modifier. While Enter is physically // held down, any note keys pressed get auto-latched into heldKeys so // they keep ringing on key-up. Release Enter and the held notes stay — // you can riff freely over them. Press Enter again later to add more. // Backspace clears all holds. let recitalMode = false; // F12: hide all UI, show only colored backdrops let helpPanel = false; // Meta/Win key: show keyboard shortcut help overlay let heldKeys = new Set(); // keys that are latched (won't stop on key-up) let enterHeld = false; // true while Enter key is physically held // Per-drum voice-parameter inspector. Populated each time a drum fires // with the exact params sent to sound.synth(). Rendered above the grid // for a short window so you can see what builds up each sound. Phase 1 // is read-only; later phases will let you mouse-drag each param. let lastDrumInspect = null; // { letter, name, voices: [{type,tone,dur,vol,atk,dcy,pan}], until } let drumInspectBuilder = null; let lastWarInspect = null; // { letter, name, model, params, until } // Geometry of the most recently rendered inspector cards — populated // each frame by paint() so act() can hit-test for tap+drag editing. // Each entry: { x, y, w, h, key, weapon, model }. Null when no // inspector is showing. let warInspectCardRects = null; // Drag-to-edit state. Null when not dragging. While dragging, draw // events (vertical) update WAR_PARAM_OVERRIDES for the captured card. let editingWarCard = null; // { key, weapon, model, startVal, startY, lastVal } // Effective pitch shift blended by FX mix (0% fx = no pitch shift) function effectivePitchShift() { return pitchShift * fxMix; } // Metronome state let metronomeEnabled = false; let metronomeBPM = 120; let metronomeBeatCount = 0; let metronomeFlash = 0; let metronomeVisualPhase = 0; let metronomePendulumAngle = 0; // swinging pendulum animation (-1..1) let metronomePendulumDir = 1; // Clock time sync let clockOffset = 0; // ms offset added to Date.now() for accurate time let clockSynced = false; // true once we have a server time sync let clockSyncFrame = 0; // frame counter for periodic resync function syncedNow() { return Date.now() + clockOffset; } // FX rows: dry/wet, echo, pitch, bitcrush, volume, drive. let echoMix = 0; let bitcrushMix = 0; let echoDragging = false; let pitchDragging = false; let fxDragging = false; let bitcrushDragging = false; // FX chain dry/wet mix (0=dry, 1=fully wet) let fxMix = 1; let volDragging = false; let brtDragging = false; // Master volume (user-controlled, separate from system_volume hardware // mixer). Slider range 0..1 maps to audio gain 0..2 (0..200%), so // slider at 0.5 = unity gain (1.0×). Default 0.5 means the slider // starts at the unity tick so the device sounds identical to pre-slider // builds out of the box. let masterVolMix = 0.5; let masterVolDragging = false; // Drive (tanh soft-sat dry/wet). 0 = clean, 1 = full drive. Applied // BEFORE master volume so the slider feels like a tone control. let driveMix = 0; let driveDragging = false; // Wobble / flange dry/wet. 0 = clean, 1 = full flange. Modulated short // delay with moderate feedback — "jet sweep" character. let wobbleMix = 0; let wobbleDragging = false; // Waveform-strip view cursor — how many seconds in the past the playhead // sits relative to the live audio edge. 0 = live (wave drifts LEFT as // real time advances). Grows when spacebar is held (wave drifts RIGHT, // backwards-replay scrub). Shrinks back to 0 on release. let waveViewOffsetSec = 0; // Max retreat — aligned with the reverse-playback capture buffer // (REVERSE_MAX_AGE_MS = 12 s) so the visual cursor keeps scrolling // backward through the entire captured window while spacebar is held. // Previously capped at 2 s, which made the wave visibly freeze after // ~2 s of hold even though there was plenty more history available; // now the cursor stays in sync with how far back the reverse voice can // actually reach. const WAVE_VIEW_MAX_OFFSET_SEC = 12.0; // Smoothed "drift speed" of the displayed time-range. // = +1.0 → display advances at 1× real-time → wave drifts LEFT (live) // = 0.0 → display frozen → wave doesn't drift // = -1.0 → display retreats at 1× real-time → wave drifts RIGHT (replay) // Used only in LIVE mode for smooth drift; during spacebar-held reverse // we drive waveViewOffsetSec directly from elapsed-since-press so the // visual needle aligns with the replay voice's actual position. let waveDriftSpeed = 1.0; // Wall-clock timestamp (ms) when spacebar was last pressed. 0 when not // in reverse mode. Used to compute the exact offset so the cursor in // the visualizer matches what the replay voice is playing right now. let spacePressStartMs = 0; // Pitch shift — assignable to either trackpad axis let pitchShift = 0; // -1 to +1, 0 = no shift let lastAppliedPitch = 0; // last pitch actually sent to synths (throttle) // Trackpad FX control (\ toggles on/off) let trackpadFX = false; let trackpadEffectBindings = { echo: { x: true, y: false }, pitch: { x: false, y: true }, crush: { x: false, y: false }, volume: { x: false, y: false }, drive: { x: false, y: false }, wobble: { x: false, y: false }, }; function clampRange(value, min, max) { return Math.max(min, Math.min(max, value)); } function clamp01(value) { return clampRange(value, 0, 1); } function pointInRect(x, y, rect) { return !!rect && x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h; } function setEchoMixValue(value, sound, commit = true) { const next = clamp01(value); const changed = Math.abs(next - echoMix) > 0.0005; echoMix = next; if (commit) sound?.room?.setMix?.(echoMix); return changed; } function setBitcrushMixValue(value, sound, commit = true) { const next = clamp01(value); const changed = Math.abs(next - bitcrushMix) > 0.0005; bitcrushMix = next; if (commit) sound?.glitch?.setMix?.(bitcrushMix); return changed; } function setMasterVolMixValue(value, sound, commit = true) { // Slider range 0..1 maps to audio gain 0..2 so the UI stays in the // same 0..100% pattern as the other FX rows. 50% = unity, 100% = 2x. const next = clamp01(value); const changed = Math.abs(next - masterVolMix) > 0.0005; masterVolMix = next; if (commit) sound?.volume?.setMix?.(masterVolMix * 2); return changed; } function setDriveMixValue(value, sound, commit = true) { const next = clamp01(value); const changed = Math.abs(next - driveMix) > 0.0005; driveMix = next; if (commit) sound?.drive?.setMix?.(driveMix); return changed; } function setWobbleMixValue(value, sound, commit = true) { const next = clamp01(value); const changed = Math.abs(next - wobbleMix) > 0.0005; wobbleMix = next; if (commit) sound?.wobble?.setMix?.(wobbleMix); return changed; } function applyPitchShiftToActiveSounds(force = false) { const ep = effectivePitchShift(); if (!force && Math.abs(ep - lastAppliedPitch) <= 0.001) return false; lastAppliedPitch = ep; const factor = Math.pow(2, ep); for (const k of Object.keys(sounds)) { const s = sounds[k]; if (s && s.synth && s.baseFreq) { if (s.isSample) { const sb = s.sampleBase || SAMPLE_BASE_FREQ; s.synth.update({ tone: clampSampleTone(s.baseFreq * factor, sb), base: sb }); } else s.synth.update({ tone: s.baseFreq * factor }); } } updateReversePlaybackPitch(); updateActivePercussionVoices(force); return true; } function setPitchShiftValue(value, force = true) { const next = clampRange(value, -1, 1); const changed = Math.abs(next - pitchShift) > 0.0005; pitchShift = next; if (force) applyPitchShiftToActiveSounds(true); return changed; } function toggleTrackpadBinding(effect, axis) { if (!trackpadEffectBindings[effect]) return; trackpadEffectBindings[effect][axis] = !trackpadEffectBindings[effect][axis]; } function setEffectRowFromPointer(rowId, x, row, sound, commit = true) { if (!row) return false; const norm = clamp01((x - row.sliderX) / Math.max(1, row.sliderW)); if (rowId === "fx") { fxMix = norm; if (commit) sound?.fx?.setMix?.(fxMix); return true; } if (rowId === "echo") return setEchoMixValue(norm, sound, commit); if (rowId === "crush") return setBitcrushMixValue(norm, sound, commit); if (rowId === "pitch") return setPitchShiftValue(norm * 2 - 1, commit); if (rowId === "volume") return setMasterVolMixValue(norm, sound, commit); if (rowId === "drive") return setDriveMixValue(norm, sound, commit); if (rowId === "wobble") return setWobbleMixValue(norm, sound, commit); return false; } function forEachActivePercussionHold(fn) { const seen = new Set(); const now = Date.now(); ringingPercussionHolds = ringingPercussionHolds.filter((hold) => hold && (hold.ringUntil ?? 0) > now); for (const hold of Object.values(activeDrumKeys)) { if (!hold || seen.has(hold)) continue; seen.add(hold); fn(hold); } for (const entry of Object.values(touchNotes)) { const hold = entry?.drumHold; if (!hold || seen.has(hold)) continue; seen.add(hold); fn(hold); } for (const hold of ringingPercussionHolds) { if (!hold || seen.has(hold)) continue; seen.add(hold); fn(hold); } } function updateActivePercussionVoices(force = false) { const nextPitchFactor = Math.pow(2, effectivePitchShift()); forEachActivePercussionHold((hold) => { const master = masterForSide(hold.gridOffset ?? 0); const nextVolume = (hold.baseVolume ?? hold.volume) * master; if (!force && Math.abs(nextPitchFactor - (hold.lastAppliedPitchFactor ?? hold.pitchFactor ?? nextPitchFactor)) <= 0.001 && Math.abs(nextVolume - (hold.lastAppliedVolume ?? hold.volume ?? nextVolume)) <= 0.001) { return; } hold.pitchFactor = nextPitchFactor; hold.volume = nextVolume; hold.lastAppliedPitchFactor = nextPitchFactor; hold.lastAppliedVolume = nextVolume; for (const entry of hold.voices || []) { if (!entry?.handle?.update) continue; const patch = {}; if (entry.baseTone !== undefined) patch.tone = entry.baseTone * nextPitchFactor; if (entry.sampleBase !== undefined) patch.base = entry.sampleBase; if (entry.baseVolumeUnit !== undefined) patch.volume = entry.baseVolumeUnit * nextVolume; if (Object.keys(patch).length > 0) { try { entry.handle.update(patch); } catch {} } if (entry.releaseBaseTone !== undefined) { entry.releaseUpdate = { ...(entry.releaseUpdate || {}), tone: entry.releaseBaseTone * nextPitchFactor }; } if (entry.releaseBaseVolumeUnit !== undefined) { entry.releaseUpdate = { ...(entry.releaseUpdate || {}), volume: entry.releaseBaseVolumeUnit * nextVolume }; } } }); } // Dark mode: auto based on LA time (7pm-7am) // UEFI clock is UTC; LA is UTC-7 (PDT) or UTC-8 (PST) // DST: second Sunday of March 2am → first Sunday of November 2am function getLAOffset() { const now = new Date(); const y = now.getUTCFullYear(), m = now.getUTCMonth(); if (m > 2 && m < 10) return 7; // Apr-Oct: always PDT if (m < 2 || m > 10) return 8; // Jan-Feb, Dec: always PST // March (m=2): DST starts second Sunday at 2am UTC-8 (10am UTC) if (m === 2) { const d1 = new Date(Date.UTC(y, 2, 1)).getUTCDay(); // day-of-week of Mar 1 const secondSun = 8 + (7 - d1) % 7; // date of second Sunday const dstStart = Date.UTC(y, 2, secondSun, 10); // 2am PST = 10am UTC return now.getTime() >= dstStart ? 7 : 8; } // November (m=10): DST ends first Sunday at 2am UTC-7 (9am UTC) const d1 = new Date(Date.UTC(y, 10, 1)).getUTCDay(); const firstSun = 1 + (7 - d1) % 7; const dstEnd = Date.UTC(y, 10, firstSun, 9); // 2am PDT = 9am UTC return now.getTime() < dstEnd ? 7 : 8; } function getLAHour() { const now = new Date(); return (now.getUTCHours() - getLAOffset() + 24) % 24; } function isDark() { const h = getLAHour(); return h >= 20 || h < 7; // dark after 8pm, light after 7am (LA time) } let dark = isDark(); // auto: dark after 7pm LA time, light before // Background color — average of active notes, lerped let bgColor = [0, 0, 0]; let bgTarget = dark ? [20, 20, 25] : [240, 238, 232]; // === DRUM BACKGROUND FLASH === // Tones paint the full-screen background from their note colors while held // (see the activeKeys loop in paint()). Drums are one-shots and don't live // in `sounds[key]`, so they need their own transient flash state. Each drum // hit pushes an entry here; paint() sums+decays them into the bg color so // a kick pulses orange, a ride pulses silver-blue, a crash pulses lavender, // etc. Rolls overlap because we keep up to 8 concurrent fading entries. const DRUM_FLASH_LIFE = 14; // ~230 ms at 60 fps let drumFlashes = []; // [{ color: [r,g,b], frame, life }] // Per-pad highlight: bumps to `frame + N` on hit so the pad outlines // itself bright for a few frames. Indexed by side+letter (e.g. "L_c", // "R_c#") so left/right pads of the same key flash independently. let padHitUntil = {}; // Cached sound API ref (for leave) let soundAPI = null; let systemAPI = null; // Tablet mode tracking let lastTabletMode = null; let usbMidiRecent = []; let usbMidiNextRefreshFrame = 0; let usbMidiTypecSignature = ""; let udpMidiBroadcast = false; let udpMidiNextHeartbeatFrame = 0; // Connectivity telemetry for the UDP midi relay overlay let udpMidiSentCount = 0; let udpMidiOnCount = 0; let udpMidiOffCount = 0; let udpMidiLastPitch = -1; let udpMidiLastVelocity = 0; let udpMidiLastSentFrame = -9999; // OS update panel state machine // states: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "flashing" | "rebooting" const OS_BASE_URL = "https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os/"; const OS_VERSION_URL = OS_BASE_URL + "native-notepat-latest.version"; const OS_VMLINUZ_URL = OS_BASE_URL + "native-notepat-latest.vmlinuz"; const OS_VMLINUZ_BYTES = 37_000_000; // ~35MB (actual size from releases.json) const OS_AUTO_CHECK_INTERVAL_FRAMES = 36_000; // 10 minutes @ 60fps const OS_AUTO_RETRY_FRAMES = 900; // 15 seconds on transient network errors // Screen state machine — only one screen renders at a time // "notepat" = pads/instrument, "os" = update panel, "wifi" = network picker let activeScreen = "notepat"; let osState = "idle"; let osCurrentVersion = ""; // set from system.version on boot let osRemoteVersion = ""; // fetched from remote let osProgress = 0; // 0.0-1.0 let osError = ""; // error message if any let osFetchPending = false; // true while waiting for version fetchResult (manual panel) let osCheckFrame = 0; // frame when we started the version fetch let osUpdatePingVersion = ""; // last version we pinged to the user let flashTargetIdx = 0; // index into system.flashTargets // Update CHECK only: a background version ping on WiFi connect that, if a // newer OS exists, speaks "system update available" and stops. NEVER // downloads, flashes, or reboots on its own — the user installs updates // manually via the OS panel. states: "idle" | "checking". let autoUpdate = { state: "idle", fetchPending: false, // true = fetchResult belongs to auto-update version check rebootFrame: 0, nextCheckFrame: 0, availableVersion: "", lastError: "", }; // WiFi UI state let wifiSelectedIdx = -1; let wifiPassword = ""; let wifiPasswordMode = false; // true = fullscreen password entry let shiftHeld = false; // Chord modifiers (Menu Band parity). Held with a note key, they fire a // triad instead of a single tone. control = major, alt = minor, // control+alt = sus2. Tracked here like shiftHeld and read at note-on. let ctrlHeld = false; let altHeld = false; // Analog keyboard (NuPhy Hall-Effect) detection. When a note-on event // arrives with an analog pressure value (0 < p < 1) that isn't the // default 1.0 stamped onto digital keypresses, we flag the session as // "analog". Rendered as a `nuphy` badge in the status bar so the user // knows velocity-sensitive play is active; stays sticky for 60 seconds // after the last analog press so a momentary digital press (e.g. the // laptop's built-in keyboard) doesn't flicker the badge off. let lastAnalogKeyAt = 0; // syncedNow() ms; 0 = never seen analog let lastAnalogPressure = 0; // last analog press value, for the debug gauge // AC chat: latest message fetched after WiFi connects let acMsg = null; // { from, text } once loaded let lastSpokenMsgKey = ""; // "from:text" of last TTS'd message (avoid repeats on reconnect) let wsStatus = ""; // "connecting" | "connected" | "error" | "" let wsConnectGrace = 0; // frames to wait before declaring error (race-condition guard) let wsReconnectTimer = 0; // frames until next reconnect attempt let wsReconnectAttempts = 0; // for exponential backoff (reset on clean connect) let wsBacklogProcessed = false; // true once we've parsed the initial history dump let chatMuted = true; // incoming-chat TTS off by default on native // (instrument-name / status speech is separate) let wifiWasConnected = false; let wifiConnectFrame = -9999; // frame when WiFi last connected (cooldown guard) let lastBatPercent = -1; // for battery change TTS // Auto-connect: try "aesthetic.computer" hotspot when not connected const AC_SSID = "aesthetic.computer"; const AC_PASS = "aesthetic.computer"; // Additional networks loaded from /mnt/wifi_creds.json let autoConnectFrame = 0; // counts frames; try every ~5s (300 frames) let autoConnectBlink = 0; // blink counter for antenna icon while polling let autoConnectTry = 0; // increments each attempt let connectStartFrame = 0; // frame when current connect began (for timeout) // Saved WiFi credentials — persisted to /mnt/wifi_creds.json (USB EFI partition) const CREDS_PATH = "/mnt/wifi_creds.json"; let savedCreds = []; // [{ssid, pass}, ...] loaded at boot // === DJ DECK (ported from dj.mjs) === // Integrated turntable playback so notepat can layer melody/percussion // over a backing track, sync tempo, and scratch — all in one piece. const DJ_AUDIO_EXTS = new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus", "wma"]); let djFiles = []; // [{path, name}] discovered on USB let djTrackIdx = 0; let djMounted = false; let djUsbConnected = false; let djLastUsbCheckFrame = 0; const DJ_USB_CHECK_ACTIVE_FRAMES = 180; // ~3s while a DJ USB is present const DJ_USB_CHECK_IDLE_FRAMES = 900; // ~15s when idle; F4 still forces an immediate rescan let djMessage = ""; let djMessageFrame = 0; let djAngle = 0; // visual spin angle (not drawn big, just for strip) let djDragging = false; // true while user is scratching the deck strip let djDragWasPlaying = false; let djDragLastX = 0; let djScratchSpeed = 0; // Tap-tempo state for syncing notepat metronome to the current track let djTapTimes = []; // last few tap timestamps (ms) let djDerivedBPM = 0; // 0 = not yet tapped, otherwise tapped BPM // Cached deck strip geometry from paint() for hit-testing in act() let djStrip = null; // { x, y, w, h, btnPlay, btnPrev, btnNext, btnScan, btnTap } // Internet radio — same Menu Band stations as radio.mjs, tuned through the // shared deck (deck 0). F5 cycles stations; layer notepat over a live feed. const RADIO_STATIONS = [ { id: "kpbj", name: "KPBJ.FM", url: "https://stream.kpbj.fm/" }, { id: "r8dio", name: "r8dio.dk", url: "https://s3.radio.co/s7cd1ffe2f/listen" }, { id: "nts1", name: "NTS 1", url: "https://stream-relay-geo.ntslive.net/stream" }, { id: "nts2", name: "NTS 2", url: "https://stream-relay-geo.ntslive.net/stream2" }, ]; let radioIdx = -1; // -1 = no station tuned yet function djTuneRadio(sound, idx) { if (RADIO_STATIONS.length === 0) return; radioIdx = (idx + RADIO_STATIONS.length) % RADIO_STATIONS.length; const st = RADIO_STATIONS[radioIdx]; djMsg("tuning " + st.name + "…"); // load briefly blocks on the connect const ok = sound?.deck?.load(0, st.url); if (ok) { sound.deck.setSpeed?.(0, 1); sound.deck.play(0); djMsg("📻 " + st.name); sound?.speak?.(st.name); } else { djMsg("can't reach " + st.name); } } function djIsAudio(name) { if (name.startsWith(".")) return false; const dot = name.lastIndexOf("."); return dot >= 0 && DJ_AUDIO_EXTS.has(name.slice(dot + 1).toLowerCase()); } function djScanDir(system, path, results, depth) { if (depth > 4) return; const listing = system?.listDir?.(path); if (!listing) return; for (const f of listing) { const full = path + "/" + f.name; if (f.isDir && !f.name.startsWith(".")) djScanDir(system, full, results, depth + 1); else if (djIsAudio(f.name)) results.push({ path: full, name: f.name }); } } function djScan(system, sound) { djFiles = []; for (const d of ["/media", "/mnt/samples", "/mnt"]) djScanDir(system, d, djFiles, 0); djFiles.sort((a, b) => a.name.localeCompare(b.name)); djMsg(djFiles.length > 0 ? `${djFiles.length} tracks` : "no tracks found"); sound?.speak?.(djFiles.length > 0 ? `${djFiles.length} tracks` : "no tracks"); } function djLoadTrack(sound) { if (djFiles.length === 0) return; if (djTrackIdx >= djFiles.length) djTrackIdx = 0; if (djTrackIdx < 0) djTrackIdx = djFiles.length - 1; const f = djFiles[djTrackIdx]; const ok = sound?.deck?.load?.(0, f.path); if (ok) { const name = f.name.replace(/\.[^.]+$/, ""); djMsg(name); sound?.speak?.(name); sound.deck.play(0); } else { djMsg("failed: " + f.name); } } function djMsg(t) { djMessage = t; djMessageFrame = frame; } function djFmt(s) { if (!s || s < 0) return "0:00"; return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; } function djTogglePlay(sound) { const dk = sound?.deck; const d = dk?.decks?.[0]; if (!d?.loaded) return; if (d.playing) { dk.pause(0); djMsg("paused"); } else { dk.play(0); djMsg("playing"); } } // Tap-tempo: each call records a timestamp; BPM = 60000 / avg interval across // the last 4 intervals. Tapping in time with the deck track sets djDerivedBPM // and also updates notepat's metronomeBPM so the click track lines up. function djTapTempo() { const now = Date.now(); // Reset if the tap gap is too big (>2s means a fresh tap sequence) if (djTapTimes.length > 0 && now - djTapTimes[djTapTimes.length - 1] > 2000) { djTapTimes = []; } djTapTimes.push(now); if (djTapTimes.length > 5) djTapTimes.shift(); if (djTapTimes.length >= 2) { let sum = 0; for (let i = 1; i < djTapTimes.length; i++) sum += djTapTimes[i] - djTapTimes[i - 1]; const avg = sum / (djTapTimes.length - 1); if (avg > 150 && avg < 2000) { djDerivedBPM = Math.round(60000 / avg); metronomeBPM = Math.max(20, Math.min(300, djDerivedBPM)); metronomeBeatCount = Math.floor(syncedNow() / (60000 / metronomeBPM)) - 1; djMsg(`${djDerivedBPM} bpm`); } } } // US-QWERTY shift map for bare-metal text input (no OS layout handling) const SHIFT_MAP = { "1":"!","2":"@","3":"#","4":"$","5":"%","6":"^","7":"&","8":"*","9":"(","0":")", "-":"_","=":"+","[":"{","]":"}",";":":","'":'"',",":"<",".":">","/":"?","\\":"|","`":"~", }; let wifiCursorBlink = 0; // cursor blink counter // Touch-note state (for clickable grid buttons) let touchNotes = {}; // pointer id -> { key, drumHold? } // Hover / cursor position (updated from touch + draw events) let hoverX = -1, hoverY = -1; // Chromatic note order per octave const CHROMATIC = ["c","c#","d","d#","e","f","f#","g","g#","a","a#","b"]; // Keyboard mapping matching notepat.mjs NOTE_TO_KEYBOARD_KEY const NOTE_TO_KEY = { "c": "c", "c#": "v", "d": "d", "d#": "s", "e": "e", "f": "f", "f#": "w", "g": "g", "g#": "r", "a": "a", "a#": "q", "b": "b", "+c": "h", "+c#": "t", "+d": "i", "+d#": "y", "+e": "j", "+f": "k", "+f#": "u", "+g": "l", "+g#": "o", "+a": "m", "+a#": "p", "+b": "n", "++c": ";", "++c#": "'", "++d": "]", "-a#": "z", "-b": "x", }; const KEY_TO_NOTE = {}; for (const [note, key] of Object.entries(NOTE_TO_KEY)) { KEY_TO_NOTE[key] = note; } function parseNote(name) { if (name.startsWith("++")) return [name.slice(2), 2]; if (name.startsWith("+")) return [name.slice(1), 1]; if (name.startsWith("-")) return [name.slice(1), -1]; return [name, 0]; } // === GENERAL MIDI INSTRUMENT MODEL (Menu Band parity) === // There is no GM soundfont on bare metal, so each of the 128 GM programs // is mapped onto one of the engine's native waveforms via its family. // 16 families of 8 → a compact recipe table (NOT 128 hand-tuned lines). // A recipe = { wave, attack, decay, sustain, volume, detune? }. When a // melodic side has a gmProgram selected, note-on uses the recipe instead // of the freely-cycled `wave`. The custom kits (perc/war/zoo/lasers) are // untouched — GM only applies to the "off"/melodic layer. const GM_FAMILY_RECIPES = [ // 0 Piano (0-7) — modal grand { wave: "piano", attack: 0.002, decay: 0.9, sustain: false, volume: 0.55, linger: "sustain" }, // 1 Chromatic/mallet (8-15) — bell-like ping { wave: "triangle", attack: 0.001, decay: 0.35, sustain: false, volume: 0.5, linger: "staccato" }, // 2 Organ (16-23) — held square { wave: "square", attack: 0.004, decay: 0.2, sustain: true, volume: 0.4, linger: "sustain" }, // 3 Guitar (24-31) — Karplus pluck { wave: "harp", attack: 0.001, decay: 0.6, sustain: false, volume: 0.5, linger: "staccato" }, // 4 Bass (32-39) — low body, medium decay { wave: "triangle", attack: 0.002, decay: 0.5, sustain: false, volume: 0.6, linger: "staccato" }, // 5 Strings (40-47) — bowed swell { wave: "sawtooth", attack: 0.08, decay: 0.4, sustain: true, volume: 0.4, linger: "sustain" }, // 6 Ensemble (48-55) — detuned section { wave: "sawtooth", attack: 0.06, decay: 0.4, sustain: true, volume: 0.38, detune: 0.06, linger: "sustain" }, // 7 Brass (56-63) — bright sustained saw { wave: "sawtooth", attack: 0.02, decay: 0.3, sustain: true, volume: 0.45, linger: "sustain" }, // 8 Reed (64-71) — reedy square/triangle { wave: "square", attack: 0.01, decay: 0.3, sustain: true, volume: 0.4, linger: "sustain" }, // 9 Pipe (72-79) — waveguide flute (perfect) { wave: "whistle", attack: 0.03, decay: 0.3, sustain: true, volume: 0.42, linger: "sustain" }, // 10 Synth Lead (80-87) — bright square lead { wave: "square", attack: 0.004, decay: 0.3, sustain: true, volume: 0.42, linger: "sustain" }, // 11 Synth Pad (88-95) — slow sine swell { wave: "sine", attack: 0.12, decay: 0.5, sustain: true, volume: 0.4, linger: "sustain" }, // 12 Synth FX (96-103) — airy sine + noise { wave: "sine", attack: 0.06, decay: 0.4, sustain: true, volume: 0.38, noise: true, linger: "sustain" }, // 13 Ethnic (104-111) — plucked/whistled { wave: "harp", attack: 0.001, decay: 0.6, sustain: false, volume: 0.5, linger: "staccato" }, // 14 Percussive (112-119) — short triangle ping { wave: "triangle", attack: 0.001, decay: 0.18, sustain: false, volume: 0.5, linger: "staccato" }, // 15 Sound FX (120-127) — noise { wave: "noise", attack: 0.001, decay: 0.3, sustain: false, volume: 0.45, linger: "sustain" }, ]; // Per-family pipe override: program 78 (Whistle) / 73 (Flute) etc. all stay // whistle. Bass split: synth-pluck basses (36-37 slap) want a faster decay. function gmRecipe(program) { const p = Math.max(0, Math.min(127, program | 0)); const recipe = { ...GM_FAMILY_RECIPES[Math.floor(p / 8)] }; // Light per-program nuance: low basses sit on sine for a rounder body. if (p >= 32 && p <= 33) recipe.wave = "sine"; return recipe; } // The 128 General MIDI Level 1 program names, VERBATIM from the MMA // RP-003 "General MIDI System Level 1" spec, Table 2 (program 1 = index 0). // Quirks are intentional and spec-exact: "Clavi", lowercase "Guitar // harmonics", one-word "SynthStrings"/"SynthBrass", "Synth Voice" (#55), // two-word "Bag pipe" (#110), "Shanai" (#112). const GM_PROGRAM_NAMES = [ "Acoustic Grand Piano","Bright Acoustic Piano","Electric Grand Piano","Honky-tonk Piano", "Electric Piano 1","Electric Piano 2","Harpsichord","Clavi", "Celesta","Glockenspiel","Music Box","Vibraphone","Marimba","Xylophone","Tubular Bells","Dulcimer", "Drawbar Organ","Percussive Organ","Rock Organ","Church Organ","Reed Organ","Accordion","Harmonica","Tango Accordion", "Acoustic Guitar (nylon)","Acoustic Guitar (steel)","Electric Guitar (jazz)","Electric Guitar (clean)", "Electric Guitar (muted)","Overdriven Guitar","Distortion Guitar","Guitar harmonics", "Acoustic Bass","Electric Bass (finger)","Electric Bass (pick)","Fretless Bass", "Slap Bass 1","Slap Bass 2","Synth Bass 1","Synth Bass 2", "Violin","Viola","Cello","Contrabass","Tremolo Strings","Pizzicato Strings","Orchestral Harp","Timpani", "String Ensemble 1","String Ensemble 2","SynthStrings 1","SynthStrings 2","Choir Aahs","Voice Oohs","Synth Voice","Orchestra Hit", "Trumpet","Trombone","Tuba","Muted Trumpet","French Horn","Brass Section","SynthBrass 1","SynthBrass 2", "Soprano Sax","Alto Sax","Tenor Sax","Baritone Sax","Oboe","English Horn","Bassoon","Clarinet", "Piccolo","Flute","Recorder","Pan Flute","Blown Bottle","Shakuhachi","Whistle","Ocarina", "Lead 1 (square)","Lead 2 (sawtooth)","Lead 3 (calliope)","Lead 4 (chiff)","Lead 5 (charang)","Lead 6 (voice)","Lead 7 (fifths)","Lead 8 (bass + lead)", "Pad 1 (new age)","Pad 2 (warm)","Pad 3 (polysynth)","Pad 4 (choir)","Pad 5 (bowed)","Pad 6 (metallic)","Pad 7 (halo)","Pad 8 (sweep)", "FX 1 (rain)","FX 2 (soundtrack)","FX 3 (crystal)","FX 4 (atmosphere)","FX 5 (brightness)","FX 6 (goblins)","FX 7 (echoes)","FX 8 (sci-fi)", "Sitar","Banjo","Shamisen","Koto","Kalimba","Bag pipe","Fiddle","Shanai", "Tinkle Bell","Agogo","Steel Drums","Woodblock","Taiko Drum","Melodic Tom","Synth Drum","Reverse Cymbal", "Guitar Fret Noise","Breath Noise","Seashore","Bird Tweet","Telephone Ring","Helicopter","Applause","Gunshot", ]; // GM selection state. gmProgram === null means "no GM" (use the freely // cycled `wave`). gmPassthrough === true means digit "0" was chosen: relay // MIDI but don't synthesize a local voice. Digit accumulation (max 3) with // a >0.8s gap starting a fresh buffer, exactly like Menu Band. let gmProgram = null; // 0..127, or null let gmPassthrough = false; // true = MIDI relay only, no local synth let gmDigitBuffer = ""; // accumulating digits let gmDigitLastMs = 0; // Date.now() of last digit let gmNotice = null; // { text, until } — transient program readout // Voices currently lingering (shift-held release tail). Each entry rides a // scheduled kill; tracked so stopAllSounds can sweep them too. let lingerVoices = []; // True when at least one grid side is melodic (kit "off"). GM program + // chord modifiers only apply on melodic sides. function anyMelodicSide() { return kitLeft === "off" || kitRight === "off"; } const LEFT_GRID = [ ["c", "c#", "d", "d#"], ["e", "f", "f#", "g"], ["g#", "a", "a#", "b"], ]; const RIGHT_GRID = [ ["+c", "+c#", "+d", "+d#"], ["+e", "+f", "+f#", "+g"], ["+g#", "+a", "+a#", "+b"], ]; const NOTE_COLORS = { c: [255, 30, 30], "c#": [255, 80, 0], d: [255, 150, 0], "d#": [200, 200, 0], e: [230, 220, 0], f: [30, 200, 30], "f#": [0, 200, 180], g: [30, 100, 255], "g#": [80, 50, 255], a: [140, 30, 220], "a#": [200, 30, 150], b: [200, 50, 255], }; // === KIT LAYOUT === // Pgup cycles the left octave grid's kit through off → perc → warA → warB → off. // Pgdn does the same for the right grid. "perc" replaces the 12 notes // with drum hits (TR-808 / 909 analogues). "warA" / "warB" replace them // with the same 12 weapons rendered via two different synthesis models — // warA uses each preset's default (mostly classic 3-layer crack/boom/ // tail) and warB forces every weapon through the physical DWG bore. Tap // pgup/pgdn between A and B to A/B-compare model timbre on the same key. let kitLeft = "off"; // "off" | "perc" | "warA" | "warB" let kitRight = "off"; // Map kit name → model arg passed to playWar / native synth suffix. function warModelFor(kit) { if (kit === "warA") return "default"; // preset-chosen (mixed) if (kit === "warB") return "physical"; // force DWG model return "default"; } function isWarKit(kit) { return kit === "warA" || kit === "warB"; } // Backwards-compatible convenience (some code paths just want a bool). function percussionActive(side) { return side === "left" ? kitLeft !== "off" : kitRight !== "off"; } // Per-octave master volume. Two vertical sliders (left of the left grid, // right of the right grid) scale every note / drum played on that side so // you can balance melody-vs-percussion or left-chord-vs-right-melody in // real time without touching the global fx/echo sliders. 0.0 = silent, // 1.0 = unity (no boost), values >1 just pass through since drums already // run hot. let leftMasterVol = 1.0; let rightMasterVol = 1.0; let leftVolDragging = false; let rightVolDragging = false; // === REVERSE PLAYBACK / INSTANT REPLAY LOOP === // Hold space to reverse-play the ACTUAL recent audio, not just re-trigger // note events. The native mixer keeps a recent mono output buffer; pressing // space snapshots the current phase, reverses it, loads it into a dedicated // global replay buffer, and starts playback immediately. Releasing space // stops that replay voice without touching the normal sample bank. // // Phase semantics stay loop-pedal-like: each press snapshots audio since the // previous press, then resets the phase start. So what happened during the // previous phase becomes the new reverse source. const REVERSE_MAX_AGE_MS = 12000; // Minimum chunk grabbed on every space-press, even if the user just // released a moment ago. Lets rapid press-release-press-release stutter // always have meaningful audio to reverse — the buffer still contains // the last ~500 ms of output so we re-snapshot it each press. const REVERSE_MIN_CAPTURE_MS = 500; const REVERSE_MIN_BUFFER_SAMPLES = 256; let spaceHeld = false; // Last key pressed (for the top-of-screen debug/status label). Any // physical key that triggers act() stores its name here. Rendered by // the status bar when there's space. let lastKeyPressed = ""; let lastKeyFrame = 0; // Attack / Decay envelope modes — cycled with , and . keys // Each has 3 states: "zero" (instant), "short", "long" const ATTACK_MODES = ["zero", "short", "long"]; const DECAY_MODES = ["zero", "short", "long"]; let attackModeIdx = 1; // default: short let decayModeIdx = 1; // default: short // Zero still has a tiny ramp (~3ms) to avoid click/pop transients at // note onset. The audio engine needs at least a short attack to settle // the biquad LPF on noise-type waveforms without a discontinuity. const ATTACK_VALUES = { zero: 0.003, short: 0.02, long: 0.12 }; // Decay = the release-time fade when a key is lifted (sustained notes // have infinite duration, so the synth's own decay param never fires — // we pass this through as the `fade` arg to sound.kill()). const DECAY_VALUES = { zero: 0.015, short: 0.1, long: 0.8 }; let envelopeNotice = null; // { text, icon, until } — transient overlay // Arrow-key octave offsets for left and right grids (added to base `octave`) let leftOctaveOffset = 0; // left grid plays at octave + leftOctaveOffset let rightOctaveOffset = 1; // right grid plays at octave + rightOctaveOffset (default +1) let arrowSelectedSide = 0; // 0 = left, 1 = right (for up/down volume) let reversePhaseStartMs = Date.now(); let reversePlaybackSound = null; function recordPlayback(_entry) {} function reversePlaybackTone() { return SAMPLE_BASE_FREQ * Math.pow(2, effectivePitchShift()); } function updateReversePlaybackPitch() { if (reversePlaybackSound?.update) { reversePlaybackSound.update({ tone: reversePlaybackTone(), base: SAMPLE_BASE_FREQ, }); } } function stopReversePlayback(sound) { if (!reversePlaybackSound) return; if (sound?.replay?.kill) sound.replay.kill(reversePlaybackSound, 0.03); else sound?.kill?.(reversePlaybackSound, 0.03); reversePlaybackSound = null; } function startReversePlayback(sound) { if (!sound?.speaker?.getRecentBuffer || !sound?.replay?.loadData || !sound?.replay?.play) { reversePhaseStartMs = Date.now(); return false; } const now = Date.now(); // Each press grabs the window of audio since the PREVIOUS press — unless // that window is short (rapid re-press), in which case we floor to // REVERSE_MIN_CAPTURE_MS so the reverse voice always has a meaningful // chunk to play. This is what makes the "press-release-press-release" // stutter gesture work: you can tap space repeatedly to keep reversing // (and loop back through) the same ~500 ms of recent audio, effectively // "repeating yourself" out of the live capture. const elapsed = Math.max(0, now - reversePhaseStartMs); const captureMs = Math.min(REVERSE_MAX_AGE_MS, Math.max(REVERSE_MIN_CAPTURE_MS, elapsed)); reversePhaseStartMs = now; stopReversePlayback(sound); const snapshot = sound.speaker.getRecentBuffer(captureMs / 1000); const src = snapshot?.data; const rate = snapshot?.rate || 0; if (!src || src.length < REVERSE_MIN_BUFFER_SAMPLES || rate <= 0) return false; // Build a REVERSE-ONLY loop buffer. Earlier this was a ping-pong // (reverse half then forward half), but the forward half was being // perceived as "extra time tacked on each spacebar press" — a hold // played the captured chunk backwards and then forwards, doubling // the loop length per cycle and adding audible silence-feeling // forward content the user didn't ask for. With reverse-only the // loop is just `len` samples long and pure backward; holding // longer just retriggers the reverse. const len = src.length; const loopBuf = new Float32Array(len); for (let i = 0, j = len - 1; i < len; i++, j--) { loopBuf[i] = src[j]; } sound.replay.loadData(loopBuf, rate); reversePlaybackSound = sound.replay.play({ tone: reversePlaybackTone(), base: SAMPLE_BASE_FREQ, volume: 1.0, pan: 0.0, // No loop. Holding space plays the captured window backwards once // and falls into silence when it reaches the oldest moment. The // earlier loop:true behavior wrapped the reversed buffer back to // its start, which read as a stuck "loop mode" the moment the // hold lasted longer than the captured window. Press space again // to re-snapshot and reverse the next window. loop: false, }); return !!reversePlaybackSound; } // Returns the master volume for the grid that produced a note, based on the // gridOffset we pass through from parseNote / hitTestGrid (0 = left, 1 = right). function masterForSide(gridOffset) { return gridOffset === 1 ? rightMasterVol : leftMasterVol; } let percussionNotice = null; // { text, until } — transient visual flash let percussionSampleBank = {}; // drumName -> { data: Float32Array, len, rate } // Drum layout, labels, colors, notation glyphs, and the synth kernel itself // are shared with the web notepat in /lib/percussion.mjs. Locally we just // wrap the kernel so we can keep feeding the drum-voice inspector overlay. // === WAR KIT === // Natural notes = 7 core weapons; sharps = 5 accent items. Each name // matches a GunPreset in the native WAVE_GUN synth (see audio.c // gun_presets[]). The kit parallels the perc layout so muscle memory // transfers: one-shots land on perc-one-shot keys, sustained fire on // sustained perc keys (LMG ↔ open hat, sniper ↔ ride, grenade ↔ crash). const WAR_NAMES = { c: "pistol", d: "rifle", e: "shotgun", f: "suppressed", g: "smg", a: "lmg", b: "sniper", "c#": "grenade", "d#": "rpg", "f#": "reload", "g#": "cock", "a#": "ricochet", }; // Numeric params per weapon × model. Mirrors gun_presets[] in audio.c. // Each entry is { paramKey: defaultValue }. The inspector renders one // card per key and tap+drag on a card edits the value. Edits live in // WAR_PARAM_OVERRIDES (per-session) and get merged into the synth call // via paramOverridesFor(); the original WAR_PARAMS values stay frozen // as the "default" the user is edited away from. // // Kept in sync with audio.c gun_presets[] manually — when retuning a // preset there, mirror it here so the inspector shows the right baseline. const WAR_PARAMS = { pistol: { classic: { click_amp: 0.65, click_decay_ms: 0.5, crack_amp: 0.95, crack_decay_ms: 7.0, crack_fc: 3800, crack_q: 2.6, boom_amp: 0.55, boom_freq_start: 220, boom_freq_end: 55, boom_pitch_decay_ms: 14, boom_amp_decay_ms: 55, tail_amp: 0.35, tail_attack_ms: 0, tail_decay_ms: 110, tail_fc: 900, tail_q: 0.8 }, physical: { bore_length_s: 0.000588, bore_loss: 0.55, breech_reflect: 0.92, pressure: 1.2, env_rate: 3000, noise_gain: 0.6, body_freq0: 1500, body_freq1: 4000, body_freq2: 8500, body_q0: 12, body_q1: 10, body_q2: 8, body_amp0: 0.30, body_amp1: 0.20, body_amp2: 0.15, radiation: 0.985 }, }, rifle: { classic: { click_amp: 0.75, click_decay_ms: 0.6, crack_amp: 1.05, crack_decay_ms: 8.0, crack_fc: 4500, crack_q: 3.0, boom_amp: 0.70, boom_freq_start: 280, boom_freq_end: 50, boom_pitch_decay_ms: 18, boom_amp_decay_ms: 90, tail_amp: 0.45, tail_attack_ms: 0, tail_decay_ms: 220, tail_fc: 1100, tail_q: 0.7 }, physical: { bore_length_s: 0.00235, bore_loss: 0.50, breech_reflect: 0.95, pressure: 1.5, env_rate: 2500, noise_gain: 0.5, body_freq0: 800, body_freq1: 2400, body_freq2: 6000, body_q0: 14, body_q1: 12, body_q2: 10, body_amp0: 0.35, body_amp1: 0.25, body_amp2: 0.15, radiation: 0.988 }, }, shotgun: { classic: { click_amp: 0.55, click_decay_ms: 0.8, crack_amp: 0.65, crack_decay_ms: 12, crack_fc: 2200, crack_q: 1.8, boom_amp: 1.10, boom_freq_start: 260, boom_freq_end: 38, boom_pitch_decay_ms: 22, boom_amp_decay_ms: 130, tail_amp: 0.85, tail_attack_ms: 4, tail_decay_ms: 380, tail_fc: 700, tail_q: 0.6 }, physical: { bore_length_s: 0.00388, bore_loss: 0.40, breech_reflect: 0.88, pressure: 1.8, env_rate: 1800, noise_gain: 0.9, body_freq0: 400, body_freq1: 1200, body_freq2: 3500, body_q0: 10, body_q1: 8, body_q2: 7, body_amp0: 0.40, body_amp1: 0.25, body_amp2: 0.15, radiation: 0.965 }, }, smg: { classic: { click_amp: 0.55, click_decay_ms: 0.4, crack_amp: 0.85, crack_decay_ms: 5.0, crack_fc: 4200, crack_q: 2.5, boom_amp: 0.40, boom_freq_start: 200, boom_freq_end: 60, boom_pitch_decay_ms: 10, boom_amp_decay_ms: 40, tail_amp: 0.28, tail_attack_ms: 0, tail_decay_ms: 80, tail_fc: 1200, tail_q: 0.7 }, physical: { bore_length_s: 0.00132, bore_loss: 0.58, breech_reflect: 0.92, pressure: 1.0, env_rate: 3500, noise_gain: 0.5, body_freq0: 1200, body_freq1: 3500, body_freq2: 7500, body_q0: 12, body_q1: 10, body_q2: 8, body_amp0: 0.30, body_amp1: 0.20, body_amp2: 0.13, radiation: 0.978 }, }, suppressed: { classic: { click_amp: 0.08, click_decay_ms: 0.4, crack_amp: 0.30, crack_decay_ms: 6.0, crack_fc: 1600, crack_q: 1.1, boom_amp: 0.10, boom_freq_start: 150, boom_freq_end: 80, boom_pitch_decay_ms: 8, boom_amp_decay_ms: 30, tail_amp: 0.85, tail_attack_ms: 6, tail_decay_ms: 140, tail_fc: 1800, tail_q: 0.6 }, physical: { bore_length_s: 0.00100, bore_loss: 0.85, breech_reflect: 0.80, pressure: 0.5, env_rate: 1500, noise_gain: 1.0, body_freq0: 600, body_freq1: 1500, body_freq2: 3000, body_q0: 6, body_q1: 5, body_q2: 4, body_amp0: 0.15, body_amp1: 0.10, body_amp2: 0.05, radiation: 0.85 }, }, lmg: { classic: { click_amp: 0.55, click_decay_ms: 0.5, crack_amp: 0.85, crack_decay_ms: 7.0, crack_fc: 3500, crack_q: 2.6, boom_amp: 0.65, boom_freq_start: 250, boom_freq_end: 48, boom_pitch_decay_ms: 16, boom_amp_decay_ms: 75, tail_amp: 0.40, tail_attack_ms: 0, tail_decay_ms: 160, tail_fc: 950, tail_q: 0.7 }, physical: { bore_length_s: 0.00329, bore_loss: 0.48, breech_reflect: 0.94, pressure: 1.4, env_rate: 2200, noise_gain: 0.55, body_freq0: 600, body_freq1: 1800, body_freq2: 4500, body_q0: 12, body_q1: 10, body_q2: 8, body_amp0: 0.35, body_amp1: 0.25, body_amp2: 0.15, radiation: 0.982 }, }, sniper: { classic: { click_amp: 0.85, click_decay_ms: 0.7, crack_amp: 1.20, crack_decay_ms: 11, crack_fc: 5000, crack_q: 3.2, boom_amp: 1.20, boom_freq_start: 320, boom_freq_end: 36, boom_pitch_decay_ms: 28, boom_amp_decay_ms: 180, tail_amp: 0.70, tail_attack_ms: 3, tail_decay_ms: 500, tail_fc: 850, tail_q: 0.8 }, physical: { bore_length_s: 0.00435, bore_loss: 0.35, breech_reflect: 0.97, pressure: 2.0, env_rate: 1500, noise_gain: 0.7, body_freq0: 350, body_freq1: 950, body_freq2: 2800, body_q0: 14, body_q1: 12, body_q2: 10, body_amp0: 0.50, body_amp1: 0.30, body_amp2: 0.15, radiation: 0.992 }, }, grenade: { classic: { click_amp: 0.40, click_decay_ms: 1.0, crack_amp: 0.45, crack_decay_ms: 25, crack_fc: 800, crack_q: 0.7, boom_amp: 1.50, boom_freq_start: 150, boom_freq_end: 28, boom_pitch_decay_ms: 60, boom_amp_decay_ms: 350, tail_amp: 1.50, tail_attack_ms: 12, tail_decay_ms: 800, tail_fc: 400, tail_q: 0.4 }, physical: { bore_length_s: 0.01000, bore_loss: 0.25, breech_reflect: 0.60, pressure: 1.6, env_rate: 400, noise_gain: 1.5, body_freq0: 80, body_freq1: 250, body_freq2: 1200, body_q0: 6, body_q1: 5, body_q2: 4, body_amp0: 0.60, body_amp1: 0.35, body_amp2: 0.15, radiation: 0.70 }, }, rpg: { classic: { click_amp: 0.30, click_decay_ms: 0.8, crack_amp: 0.40, crack_decay_ms: 20, crack_fc: 1500, crack_q: 0.8, boom_amp: 0.30, boom_freq_start: 120, boom_freq_end: 60, boom_pitch_decay_ms: 30, boom_amp_decay_ms: 100, tail_amp: 2.00, tail_attack_ms: 80, tail_decay_ms: 600, tail_fc: 600, tail_q: 0.5 }, physical: { bore_length_s: 0.00300, bore_loss: 0.30, breech_reflect: 0.50, pressure: 1.2, env_rate: 150, noise_gain: 2.5, body_freq0: 200, body_freq1: 600, body_freq2: 2000, body_q0: 4, body_q1: 3, body_q2: 3, body_amp0: 0.40, body_amp1: 0.30, body_amp2: 0.20, radiation: 0.60 }, }, reload: { classic: { click_amp: 0.85, click_decay_ms: 0.4, crack_amp: 0.90, crack_decay_ms: 4.0, crack_fc: 4500, crack_q: 3.0, boom_amp: 0.0, boom_freq_start: 0, boom_freq_end: 0, boom_pitch_decay_ms: 1, boom_amp_decay_ms: 1, tail_amp: 0.20, tail_attack_ms: 0, tail_decay_ms: 30, tail_fc: 2500, tail_q: 0.6 }, physical: { bore_length_s: 0.00010, bore_loss: 0.70, breech_reflect: 0.90, pressure: 0.6, env_rate: 4000, noise_gain: 0.3, body_freq0: 2200, body_freq1: 4500, body_freq2: 8000, body_q0: 10, body_q1: 8, body_q2: 6, body_amp0: 0.40, body_amp1: 0.30, body_amp2: 0.15, radiation: 0.92 }, }, cock: { classic: { click_amp: 0.90, click_decay_ms: 0.4, crack_amp: 1.00, crack_decay_ms: 5.0, crack_fc: 3800, crack_q: 3.2, boom_amp: 0.0, boom_freq_start: 0, boom_freq_end: 0, boom_pitch_decay_ms: 1, boom_amp_decay_ms: 1, tail_amp: 0.15, tail_attack_ms: 0, tail_decay_ms: 25, tail_fc: 2000, tail_q: 0.6 }, physical: { bore_length_s: 0.00015, bore_loss: 0.65, breech_reflect: 0.88, pressure: 0.7, env_rate: 3500, noise_gain: 0.35, body_freq0: 1800, body_freq1: 4200, body_freq2: 7500, body_q0: 10, body_q1: 8, body_q2: 7, body_amp0: 0.45, body_amp1: 0.25, body_amp2: 0.15, radiation: 0.92 }, }, ricochet: { classic: { click_amp: 0.40, click_decay_ms: 0.5, crack_amp: 0.35, crack_decay_ms: 7.0, crack_fc: 5500, crack_q: 3.0, boom_amp: 0.95, boom_freq_start: 1800, boom_freq_end: 1500, boom_pitch_decay_ms: 60, boom_amp_decay_ms: 350, tail_amp: 0.20, tail_attack_ms: 0, tail_decay_ms: 200, tail_fc: 3000, tail_q: 1.0 }, physical: { bore_length_s: 0.00040, bore_loss: 0.15, breech_reflect: 0.90, pressure: 0.8, env_rate: 600, noise_gain: 0.3, body_freq0: 3000, body_freq1: 5500, body_freq2: 9000, body_q0: 30, body_q1: 25, body_q2: 20, body_amp0: 0.40, body_amp1: 0.25, body_amp2: 0.15, radiation: 0.975 }, }, }; // Per-session edits — keyed `${weapon}/${model}/${paramKey}` → numeric value. // Edits override WAR_PARAMS at synth time. Reset on piece reload (no disk // persistence yet — add a system.writeFile() to ACBOOT/war-overrides.json // later if we want them sticky across reboots). const WAR_PARAM_OVERRIDES = {}; // Param value range hints used by the drag editor. Drag dy maps onto // (max−min) so you traverse the useful range with ~1 grid-height of // movement. Logarithmic for frequency/duration, linear for amps/Qs. const WAR_PARAM_RANGES = { click_amp: [0, 1.5, "lin"], click_decay_ms: [0.05, 5, "log"], crack_amp: [0, 1.5, "lin"], crack_decay_ms: [1, 60, "log"], crack_fc: [200, 12000, "log"], crack_q: [0.4, 5, "lin"], boom_amp: [0, 2, "lin"], boom_freq_start: [20, 3000, "log"], boom_freq_end: [10, 1000, "log"], boom_pitch_decay_ms: [1, 200, "log"], boom_amp_decay_ms: [5, 800, "log"], tail_amp: [0, 2.5, "lin"], tail_attack_ms: [0, 200, "log"], tail_decay_ms: [10, 2000, "log"], tail_fc: [100, 6000, "log"], tail_q: [0.3, 3, "lin"], bore_length_s: [0.00005, 0.02, "log"], bore_loss: [0.1, 0.95, "lin"], breech_reflect: [0.3, 1, "lin"], pressure: [0.2, 2.5, "lin"], env_rate: [50, 5000, "log"], noise_gain: [0, 3, "lin"], body_freq0: [40, 10000, "log"], body_freq1: [100, 10000, "log"], body_freq2: [200, 12000, "log"], body_q0: [2, 50, "log"], body_q1: [2, 40, "log"], body_q2: [2, 30, "log"], body_amp0: [0, 1, "lin"], body_amp1: [0, 1, "lin"], body_amp2: [0, 1, "lin"], radiation: [0.4, 1, "lin"], }; // Resolve the active param value for (weapon, model, key) — applies any // in-session override, else returns the WAR_PARAMS default. function paramValue(weapon, model, key) { const ovKey = `${weapon}/${model}/${key}`; if (ovKey in WAR_PARAM_OVERRIDES) return WAR_PARAM_OVERRIDES[ovKey]; return WAR_PARAMS[weapon]?.[model]?.[key]; } // Curated subset shown in the inspector — keeps the card row legible // (16 keys per preset would overflow the screen). Pick the params with // the biggest perceptual swing per layer. const INSPECTOR_KEYS = { classic: ["click_amp", "crack_fc", "crack_q", "boom_freq_start", "boom_amp_decay_ms", "tail_decay_ms"], physical: ["pressure", "env_rate", "bore_length_s", "body_freq0", "body_q0", "radiation"], }; // Compact value formatter — keeps inspector cells visually consistent. function formatParamValue(key, val) { if (val == null) return "—"; if (key.endsWith("_ms")) return val < 1 ? val.toFixed(2) : Math.round(val) + "ms"; if (key.endsWith("_fc") || key.startsWith("body_freq") || key === "boom_freq_start" || key === "boom_freq_end") { return val >= 1000 ? (val / 1000).toFixed(1) + "k" : Math.round(val); } if (key === "bore_length_s") return (val * 1000).toFixed(2) + "ms"; if (key === "env_rate") return Math.round(val); if (Math.abs(val) < 10) return val.toFixed(2); return Math.round(val); } // Build the override object for a (weapon, model) — only includes keys // the user has actually edited, so the C side only sees the deltas. function paramOverridesFor(weapon, model) { const out = {}; const prefix = `${weapon}/${model}/`; for (const k in WAR_PARAM_OVERRIDES) { if (k.startsWith(prefix)) out[k.slice(prefix.length)] = WAR_PARAM_OVERRIDES[k]; } return out; } // 3-char labels for tight pad rendering const WAR_LABELS = { c: "PST", d: "RFL", e: "SHG", f: "SUP", g: "SMG", a: "LMG", b: "SNP", "c#": "GRN", "d#": "RPG", "f#": "RLD", "g#": "CCK", "a#": "RIC", }; // Gunmetal / field palette — earthy khakis, rusts, steels, brass. const WAR_COLORS = { c: [140, 140, 150], // pistol — gunmetal steel d: [ 90, 110, 70], // rifle — olive drab e: [120, 80, 60], // shotgun — brown + steel f: [ 70, 70, 80], // suppressed — matte black g: [150, 130, 90], // smg — khaki a: [100, 90, 60], // lmg — field green b: [180, 160, 110], // sniper — desert tan "c#": [ 80, 90, 60], // grenade — dark olive "d#": [200, 100, 60], // rpg — rust orange "f#": [130, 130, 140], // reload — silver-gray "g#": [160, 140, 100], // cock — brass "a#": [210, 210, 230], // ricochet — metallic white }; // Which weapons sustain while the key is held (release kills the voice). const WAR_SUSTAIN = { g: true, // smg — full-auto, retriggers ~1000 RPM in audio.c a: true, // lmg — retriggers internally via audio.c retrig_period b: true, // sniper — long reverberant tail "c#": true,// grenade — slow rumbling release "a#": true,// ricochet — pitch drops on release (doppler) }; // Finite durations for one-shot weapons (seconds). These control the // voice's auto-kill after the internal excitation has rung out. Longer // durations preserve the body-mode ring. const WAR_DURATION = { c: 0.30, // pistol d: 0.55, // rifle e: 0.80, // shotgun f: 0.20, // suppressed g: 0.18, // smg "d#": 2.50, // rpg (motor burn + delayed boom) "f#": 0.35, // reload "g#": 0.45, // cock (rack + lock) }; // Release fade on sustained weapons (seconds). Sniper/grenade fade // slowly so the tail is audible; SMG/LMG snap off faster (muzzle cut). const WAR_RELEASE = { g: 0.05, // smg — snappy stop a: 0.08, // lmg — abrupt stop (muzzle cut) b: 0.80, // sniper — long reverberant fade "c#": 1.20,// grenade — slow rumble-out "a#": 0.35,// ricochet — natural decay after pitch drop }; // Stochastic pad graphics (same format as PERCUSSION_NOTATION). These // are just visuals — the actual synth is the DWG. Each signature hints // at the weapon's spectral content so the pad communicates "what fires". const WAR_NOTATION = { c: [["t",1500,1.0],["n",4000,0.55],["q",8500,0.4]], // pistol d: [["n",5000,0.6],["t",800,0.9],["q",2400,0.5],["q",6000,0.4]], // rifle e: [["n",400,1.6],["n",1200,1.2],["n",3500,0.8]], // shotgun f: [["n",700,0.6],["s",1500,0.3]], // suppressed g: [["n",3000,0.5],["q",1200,0.6],["q",3500,0.5]], // smg a: [["n",1800,0.8],["t",600,1.0],["q",4500,0.4]], // lmg b: [["s",350,1.5],["s",950,1.1],["n",2800,0.7]], // sniper "c#": [["s", 80,2.0],["n",250,1.5],["n",1200,1.0]], // grenade "d#": [["n",600,2.0],["n",2000,1.2]], // rpg "f#": [["q",2200,0.6],["q",4500,0.5],["n",8000,0.4]], // reload "g#": [["q",1800,0.6],["q",4200,0.5]], // cock "a#": [["s",3000,0.9],["s",5500,0.7],["s",9000,0.5]], // ricochet }; // === ZOO KIT === // 12 animal calls synthesized with classic subtractive/FM recipes. Each // sound is 1-3 voices, pitch-envelope shaped. Animal calls rely heavily // on pitch modulation — bird chirps = fast descending pulses, cat meow = // two-peak pitch envelope ("eow", the 'm' is impossible in pure // subtractive), lion roar = noise + sub-saw, etc. (Classic synthesis // references: MOD WIGGLER analog-animal thread, Robert Rich's "Bestiary"). const ZOO_NAMES = { c: "dog", d: "cat", e: "cow", f: "sheep", g: "bird", a: "pig", b: "lion", "c#": "owl", "d#": "frog", "f#": "horse", "g#": "snake", "a#": "whale", }; const ZOO_LABELS = { c: "DOG", d: "CAT", e: "COW", f: "SHP", g: "BRD", a: "PIG", b: "LION", "c#": "OWL", "d#": "FRG", "f#": "HRS", "g#": "SNK", "a#": "WHL", }; const ZOO_COLORS = { c: [200, 130, 80], // dog — warm tan d: [230, 180, 220], // cat — pink e: [220, 200, 170], // cow — cream f: [240, 240, 220], // sheep — wool white g: [140, 220, 220], // bird — pale teal a: [240, 180, 180], // pig — pink-salmon b: [220, 170, 90], // lion — golden "c#": [120, 120, 180], // owl — twilight blue "d#": [140, 220, 140], // frog — green "f#": [170, 120, 80], // horse — chestnut "g#": [170, 230, 130], // snake — lime-olive "a#": [100, 180, 230], // whale — ocean blue }; // Which zoo sounds sustain while held (release kills the voice). const ZOO_SUSTAIN = { "g#": true, // snake — long hiss }; const ZOO_RELEASE = { "g#": 0.25, }; // Fire a zoo-kit animal sound. Sample-based — plays a recording from // the named one-shot bank (audio.c: load_oneshot_bank). `pitchFactor` // is forwarded to the C engine so octave shifts speed up / slow down // the playback the same way they do for the perc kit. Sustained sounds // (e.g. snake hiss) push their handle into `holdVoices` so the matching // "up" phase can kill them via sound.zoo.kill(). function playZoo(sound, letter, volume = 1.0, pan = 0, pitchFactor = 1.0, phase = "both", holdVoices = null) { if (!sound?.zoo?.play) return; if (phase === "up") { // Release any sustained zoo voices (e.g. snake hiss) registered // by an earlier "down" phase. if (Array.isArray(holdVoices) && holdVoices.length > 0) { for (const h of holdVoices) { if (h?.handle && sound?.zoo?.kill) { sound.zoo.kill(h.handle, h.releaseFade ?? 0.1); } } holdVoices.length = 0; } return; } const name = ZOO_NAMES[letter]; if (!name) return; const v = Math.max(0.1, Math.min(2.2, volume)); const pf = Math.max(0.25, Math.min(4, pitchFactor)); const handle = sound.zoo.play(name, { volume: v, pan, pitch: pf }); if (handle && Array.isArray(holdVoices) && ZOO_SUSTAIN[letter]) { holdVoices.push({ handle, releaseFade: ZOO_RELEASE[letter] ?? 0.1 }); } } // === LASERS KIT === // 12 sci-fi weapon/energy sounds. Classic recipes: pew = descending // sine/square (Atari Asteroids), blaster = saw + noise (Star Wars), // phaser = sine modulated by noise (Star Trek's sound was derived from // War of the Worlds tape feedback — see SlashFilm). Charge-up = exp // pitch rise; warp = fast sine sweep (Doppler). const LASER_NAMES = { c: "pew", d: "blast", e: "phaser", f: "cannon", g: "stun", a: "plasma", b: "disruptor", "c#": "charge", "d#": "beam", "f#": "hit", "g#": "ricochet", "a#": "warp", }; const LASER_LABELS = { c: "PEW", d: "BLS", e: "PHS", f: "CAN", g: "STN", a: "PLS", b: "DSR", "c#": "CHG", "d#": "BEM", "f#": "HIT", "g#": "RIC", "a#": "WRP", }; const LASER_COLORS = { c: [180, 255, 140], // pew — neon green d: [255, 140, 140], // blast — red e: [180, 200, 255], // phaser — electric blue f: [255, 120, 80], // cannon — orange flame g: [200, 255, 255], // stun — pale cyan a: [220, 140, 255], // plasma — violet b: [255, 200, 80], // disruptor — gold "c#": [100, 180, 255], // charge — deep blue "d#": [140, 255, 200], // beam — lime "f#": [255, 100, 60], // hit — impact red-orange "g#": [220, 220, 180], // ricochet — metallic "a#": [200, 120, 255], // warp — magenta }; const LASER_NOTATION = { c: [["s",2500,0.6]], // pew d: [["w",150,0.8],["n",2500,0.4]], // blast e: [["s",700,0.5],["n",1200,0.3]], // phaser f: [["w",500,0.7],["s",55,0.9]], // cannon g: [["q",400,0.6]], // stun a: [["q",260,0.6]], // plasma b: [["w",520,0.6]], // disruptor "c#": [["s",800,0.6]], // charge "d#": [["q",800,0.7]], // beam "f#": [["n",3000,0.6],["s",60,1.0]], // hit "g#": [["n",4500,0.5]], // ricochet "a#": [["s",1000,0.6]], // warp }; const LASER_SUSTAIN = { "d#": true, // beam — sustained hum }; const LASER_DURATION = { c: 0.10, d: 0.20, e: 0.28, f: 0.25, g: 0.40, a: 0.50, b: 0.35, "c#": 0.75, "f#": 0.30, "g#": 0.30, "a#": 0.55, }; const LASER_RELEASE = { "d#": 0.08, }; // Fire a laser-kit sci-fi sound. Pitch envelopes and FM-ish modulation // via short stacked voices with staggered attacks. `pitchFactor` scales // tonal voices like every other kit. function playLaser(sound, letter, volume = 1.0, pan = 0, pitchFactor = 1.0, phase = "both", holdVoices = null) { if (!sound?.synth) return; if (phase === "up") return; const v = Math.max(0.1, Math.min(2.2, volume)); const pf = Math.max(0.25, Math.min(4, pitchFactor)); const isLive = phase === "down" && Array.isArray(holdVoices); const rj = (c, f) => c * (1 + (Math.random() - 0.5) * 2 * f); const fire = (params, dur) => { if (isLive && LASER_SUSTAIN[letter]) { const h = sound.synth({ ...params, duration: Infinity }); if (h) holdVoices.push({ handle: h, releaseFade: LASER_RELEASE[letter] ?? 0.08 }); } else { sound.synth({ ...params, duration: dur }); } }; switch (letter) { case "c": { // PEW (Asteroids) — 555 astable recipe: square wave with // simultaneous pitch + amplitude decay τ≈30 ms, start ~1000 Hz, // end ~200 Hz. sfxr's laserShoot uses the same shape. 4 stages // approximate the exp decay. for (let i = 0; i < 4; i++) { const t = i / 3; const tone = 1000 * Math.exp(-t * 1.6) * pf; // 1000 → 200 Hz sound.synth({ type: "square", tone, duration: 0.035, volume: rj(0.70 * Math.exp(-t * 1.3), 0.08) * v, attack: 0.0005 + t * 0.028, decay: 0.032, pan }); } break; } case "d": { // BLAST (Star Wars) — Ben Burtt guy-wire recording. The trick is // dispersive delay: high frequencies arrive first, then body. // Voice 1: square ping sweeping 3000 → 800 Hz in ~40 ms (metallic). // Voice 2: BPF-noise at ~500 Hz Q=3 (body punch), 30 ms. Voice 3: // comb-style repeated short pings (poor man's flanger) give the // "peee-uuu" after-glow. sound.synth({ type: "square", tone: 3000 * pf, duration: 0.012, volume: rj(0.70, 0.08) * v, attack: 0.001, decay: 0.011, pan }); sound.synth({ type: "square", tone: 1600 * pf, duration: 0.018, volume: rj(0.60, 0.08) * v, attack: 0.008, decay: 0.016, pan }); sound.synth({ type: "square", tone: 800 * pf, duration: 0.050, volume: rj(0.55, 0.08) * v, attack: 0.022, decay: 0.045, pan }); // Body punch — narrow-band noise around 500 Hz sound.synth({ type: "noise", tone: 500 * pf, duration: 0.04, volume: rj(0.40, 0.08) * v, attack: 0.003, decay: 0.035, pan }); // "Comb filter" trailing ping — single echo at 8 ms sound.synth({ type: "square", tone: 1200 * pf, duration: 0.03, volume: rj(0.25, 0.06) * v, attack: 0.060, decay: 0.025, pan }); break; } case "e": { // PHASER (Star Trek) — War-of-the-Worlds theremin heritage, Moog // replication: steady sine carrier + pink-noise FM/AM modulation. // Approximated here with 4 staggered sine voices at 800-1200 Hz // + gently beating slightly-detuned voice for the warble + // filtered-noise menace layer. ~1 s sustain. sound.synth({ type: "sine", tone: 1000 * pf, duration: 0.45, volume: rj(0.55, 0.06) * v, attack: 0.04, decay: 0.38, pan }); sound.synth({ type: "sine", tone: 1007 * pf, duration: 0.45, volume: rj(0.50, 0.06) * v, attack: 0.04, decay: 0.38, pan }); // 7 Hz beat sound.synth({ type: "sine", tone: 1200 * pf, duration: 0.35, volume: rj(0.22, 0.05) * v, attack: 0.10, decay: 0.30, pan }); // Pink-noise FM stand-in: band-limited noise at 300 Hz sound.synth({ type: "noise", tone: 300 * pf, duration: 0.50, volume: rj(0.18, 0.05) * v, attack: 0.06, decay: 0.44, pan }); break; } case "f": { // CANNON — sfxr explosion preset + low-end layer. Sub-thump // (sine 80 → 40 Hz in 60 ms, huge punch), noise body (LPF sweep // 3000 → 200 Hz over ~500 ms), crack layer (HPF-noise 20 ms burst). // Sub thump sweep sound.synth({ type: "sine", tone: 80 * pf, duration: 0.03, volume: rj(0.95, 0.05) * v, attack: 0.001, decay: 0.028, pan }); sound.synth({ type: "sine", tone: 55 * pf, duration: 0.08, volume: rj(0.90, 0.05) * v, attack: 0.020, decay: 0.075, pan }); sound.synth({ type: "sine", tone: 40 * pf, duration: 0.18, volume: rj(0.75, 0.05) * v, attack: 0.040, decay: 0.16, pan }); // Noise body — staged to approximate LPF sweep (high freqs fade first) sound.synth({ type: "noise", tone: 3000 * pf, duration: 0.05, volume: rj(0.55, 0.08) * v, attack: 0.001, decay: 0.045, pan }); sound.synth({ type: "noise", tone: 1200 * pf, duration: 0.15, volume: rj(0.50, 0.08) * v, attack: 0.04, decay: 0.13, pan }); sound.synth({ type: "noise", tone: 400 * pf, duration: 0.35, volume: rj(0.35, 0.08) * v, attack: 0.15, decay: 0.30, pan }); // Crack at attack sound.synth({ type: "noise", tone: 4000 * pf, duration: 0.015, volume: rj(0.50, 0.1) * v, attack: 0.0005, decay: 0.013, pan }); break; } case "g": { // STUN / zap — sfxr hitHurt style: square with slide, plus a // fake ring-mod via beating two squares 45 Hz apart. Sharp // downward slide is the canonical zap character. sound.synth({ type: "square", tone: 900 * pf, duration: 0.05, volume: rj(0.55, 0.08) * v, attack: 0.0005, decay: 0.045, pan }); sound.synth({ type: "square", tone: 500 * pf, duration: 0.09, volume: rj(0.50, 0.08) * v, attack: 0.03, decay: 0.08, pan }); sound.synth({ type: "square", tone: 300 * pf, duration: 0.12, volume: rj(0.40, 0.08) * v, attack: 0.08, decay: 0.10, pan }); // Ring-mod stand-in: second square 45 Hz higher = buzz sound.synth({ type: "square", tone: 345 * pf, duration: 0.12, volume: rj(0.30, 0.08) * v, attack: 0.09, decay: 0.10, pan }); break; } case "a": { // PLASMA — "electric hum" — detuned-square stack with slight // vibrato. Less harsh than stun, more sustained. Three voices // with ±6% detune spread + low-pass-ish harmonic (triangle at octave). const base = 260 * pf; sound.synth({ type: "square", tone: base, duration: 0.45, volume: rj(0.45, 0.08) * v, attack: 0.02, decay: 0.40, pan }); sound.synth({ type: "square", tone: base * 1.06, duration: 0.45, volume: rj(0.38, 0.08) * v, attack: 0.05, decay: 0.40, pan }); sound.synth({ type: "square", tone: base * 0.94, duration: 0.45, volume: rj(0.32, 0.08) * v, attack: 0.08, decay: 0.40, pan }); // Soft upper octave for plasma "glow" sound.synth({ type: "triangle", tone: base * 2, duration: 0.45, volume: rj(0.18, 0.05) * v, attack: 0.04, decay: 0.40, pan }); break; } case "b": { // DISRUPTOR — aggressive saw + pitched noise body + sub octave. // Blaster/cannon hybrid — stays pitched but breathier. sound.synth({ type: "sawtooth", tone: 520 * pf, duration: 0.30, volume: rj(0.55, 0.08) * v, attack: 0.003, decay: 0.28, pan }); sound.synth({ type: "sawtooth", tone: 260 * pf, duration: 0.35, volume: rj(0.45, 0.08) * v, attack: 0.008, decay: 0.33, pan }); sound.synth({ type: "noise", tone: 800 * pf, duration: 0.30, volume: rj(0.28, 0.06) * v, attack: 0.01, decay: 0.28, pan }); // Crack attack sound.synth({ type: "noise", tone: 2500 * pf, duration: 0.02, volume: rj(0.35, 0.08) * v, attack: 0.001, decay: 0.018, pan }); break; } case "c#": { // CHARGE-UP — exp pitch rise, classic sci-fi laser charge. // 6 stages, 100 Hz → 2500 Hz over 750 ms. for (let i = 0; i < 6; i++) { const t = i / 5; const tone = 100 * Math.pow(25, t) * pf; sound.synth({ type: "sine", tone, duration: 0.2, volume: rj(0.45 - t * 0.1, 0.08) * v, attack: 0.12 * i, decay: 0.18, pan }); } // Release pop at top sound.synth({ type: "square", tone: 3000 * pf, duration: 0.04, volume: rj(0.30, 0.08) * v, attack: 0.72, decay: 0.035, pan }); break; } case "d#": { // BEAM — sustained buzz, held while key is down. Square at 820 // with a perfect-fifth under it. Mild detune voice for life. const params = { type: "square", tone: 820 * pf, volume: rj(0.40, 0.06) * v, attack: 0.02, decay: 0.12, pan }; fire(params, 0.35); sound.synth({ type: "square", tone: 546 * pf, duration: 0.30, volume: rj(0.30, 0.06) * v, attack: 0.02, decay: 0.28, pan }); sound.synth({ type: "square", tone: 826 * pf, duration: 0.30, volume: rj(0.20, 0.05) * v, attack: 0.03, decay: 0.28, pan }); break; } case "f#": { // HIT — impact. Same structure as sfxr explosion but short: // crack + sub thump. Perfect for "enemy got hit" UI sounds. sound.synth({ type: "noise", tone: 3500 * pf, duration: 0.04, volume: rj(0.60, 0.08) * v, attack: 0.0005, decay: 0.035, pan }); sound.synth({ type: "sine", tone: 80 * pf, duration: 0.15, volume: rj(0.85, 0.06) * v, attack: 0.005, decay: 0.14, pan }); sound.synth({ type: "sine", tone: 50 * pf, duration: 0.25, volume: rj(0.70, 0.06) * v, attack: 0.02, decay: 0.22, pan }); sound.synth({ type: "sawtooth", tone: 140 * pf, duration: 0.06, volume: rj(0.35, 0.08) * v, attack: 0.008, decay: 0.055, pan }); break; } case "g#": { // RICOCHET — descending filtered-noise pings with a sine tail // (the "whiiizz-bing" bouncing off metal). 3 stages: initial // impact click, mid-band noise smear, high sine ping. sound.synth({ type: "noise", tone: 5500 * pf, duration: 0.025, volume: rj(0.50, 0.08) * v, attack: 0.001, decay: 0.023, pan }); sound.synth({ type: "noise", tone: 2800 * pf, duration: 0.05, volume: rj(0.35, 0.08) * v, attack: 0.020, decay: 0.045, pan }); // Dispersive "bing" tail — sine sweep 3000 → 2200 Hz in 80 ms sound.synth({ type: "sine", tone: 3000 * pf, duration: 0.04, volume: rj(0.38, 0.08) * v, attack: 0.04, decay: 0.035, pan }); sound.synth({ type: "sine", tone: 2200 * pf, duration: 0.06, volume: rj(0.28, 0.08) * v, attack: 0.08, decay: 0.055, pan }); break; } case "a#": { // WARP / JUMP — Star Trek TOS: rising brown-noise HPF sweep + // rising sine carrier, culminating in a sfxr-style "bang" at // the top (hyperspace jump). Classic warp was test-oscillator- // through-plate-reverb; we approximate with layered rising voices // + a final descending pop. // Low drone rising (sine 100 → 1000 Hz over ~1.5 s) for (let i = 0; i < 5; i++) { const t = i / 4; const tone = 100 * Math.pow(10, t) * pf; sound.synth({ type: "sine", tone, duration: 0.35, volume: rj(0.45 + t * 0.10, 0.06) * v, attack: 0.20 * i, decay: 0.25, pan }); } // Brown-noise HPF sweep (rising band center) sound.synth({ type: "noise", tone: 200 * pf, duration: 0.40, volume: rj(0.22, 0.05) * v, attack: 0.10, decay: 0.35, pan }); sound.synth({ type: "noise", tone: 800 * pf, duration: 0.40, volume: rj(0.18, 0.05) * v, attack: 0.50, decay: 0.35, pan }); sound.synth({ type: "noise", tone: 2500 * pf, duration: 0.25, volume: rj(0.14, 0.05) * v, attack: 0.95, decay: 0.22, pan }); // Climax pop into hyperspace — short descending square burst sound.synth({ type: "square", tone: 2000 * pf, duration: 0.03, volume: rj(0.45, 0.08) * v, attack: 1.15, decay: 0.028, pan }); sound.synth({ type: "square", tone: 500 * pf, duration: 0.06, volume: rj(0.35, 0.08) * v, attack: 1.18, decay: 0.055, pan }); break; } } } // === KIT HELPERS === // Unified accessors: these return the active-kit metadata for a given // (letter, gridOffset) so render code doesn't need to branch on // kitLeft/kitRight everywhere. function activeKitForOffset(offset) { // Side routing matches the grid split in hitTestGrid / the note-trigger // block: offset <= 0 belongs to the LEFT grid (includes z/x which are // offset=-1, the below-octave A#/B keys), offset >= 1 is the RIGHT grid // (+c upward). Previously only offset===0 mapped to kitLeft, so z/x // reached into whatever kit was loaded on the RIGHT side — meaning if // you armed a drum kit on the upper octave, `z` suddenly played a drum // instead of its tied A#3 tone on the left instrument. return offset <= 0 ? kitLeft : kitRight; } function kitNamesFor(kit) { if (isWarKit(kit)) return WAR_NAMES; if (kit === "perc") return PERCUSSION_NAMES; if (kit === "zoo") return ZOO_NAMES; if (kit === "lasers") return LASER_NAMES; return null; } function kitLabelsFor(kit) { if (isWarKit(kit)) return WAR_LABELS; if (kit === "perc") return PERCUSSION_LABELS; if (kit === "zoo") return ZOO_LABELS; if (kit === "lasers") return LASER_LABELS; return null; } function kitColorsFor(kit) { if (isWarKit(kit)) return WAR_COLORS; if (kit === "perc") return PERCUSSION_COLORS; if (kit === "zoo") return ZOO_COLORS; if (kit === "lasers") return LASER_COLORS; return null; } // Cycle the kit state for a side: off → perc → warA → warB → zoo → lasers → off. // warA = preset-default model per weapon (mostly classic); warB forces // physical/DWG synthesis for direct A/B comparison; zoo + lasers are // sound-design sketches grounded in classic subtractive synthesis // recipes (animal calls, sci-fi phasers/blasters/warps). function cycleKit(side) { const cur = side === "left" ? kitLeft : kitRight; const next = cur === "off" ? "perc" : cur === "perc" ? "warA" : cur === "warA" ? "warB" : cur === "warB" ? "zoo" : cur === "zoo" ? "lasers" : "off"; if (side === "left") kitLeft = next; else kitRight = next; return next; } // Return drum/weapon name if the given (letter, grid offset) is a live // kit pad, else null. offset 0 = left grid, 1 = right grid. Low/high // extras (z, x, ;, ', ]) stay melodic. function percussionDrumFor(letter, offset) { const info = kitDrumFor(letter, offset); return info ? info.name : null; } // Richer version that also returns which kit is active, so callers can // route to the right synth (playPercussion vs playWar). function kitDrumFor(letter, offset) { const kit = activeKitForOffset(offset); if (kit === "off") return null; const names = kitNamesFor(kit); if (!names || !names[letter]) return null; return { kit, name: names[letter] }; } // === DRUM PAN: QWERTY physical key position === // Pan each drum hit by where the key actually lives on the keyboard so // your fingers and the stereo field line up. Left-side keys (q/a/z/c) // pan hard left, right-side keys (p/;/m) pan hard right, middle keys // (t/g/b) stay centered. This beats kit-geometry pan (kick-center, // ride-right, crash-left) because it matches the physical gesture of // playing — you don't have to think about "which drum is this", the // pan just follows your hand. // // Values computed from QWERTY column (col - 4.5) / 4.5 * 0.9 across // three rows. Only drum keys included (c/d/e/f/g/a/b naturals + sharps // on both grids). Non-drum keys fall through to the fallback below. const KEY_QWERTY_PAN = { // Row 0 (qwerty top row): q w e r t y u i o p q: -0.9, w: -0.7, e: -0.5, r: -0.3, t: -0.1, y: 0.1, u: 0.3, i: 0.5, o: 0.7, p: 0.9, // Row 1 (asdf home row): a s d f g h j k l a: -0.9, s: -0.7, d: -0.5, f: -0.3, g: -0.1, h: 0.1, j: 0.3, k: 0.5, l: 0.7, // Row 2 (zxcv bottom row): c v b n m c: -0.5, v: -0.3, b: -0.1, n: 0.1, m: 0.3, }; function drumPanFor(letter, gridOffset, key) { // Primary: QWERTY physical column position. This is what the user sees // and feels — the pan matches where their hand is. if (key && KEY_QWERTY_PAN[key] !== undefined) { return KEY_QWERTY_PAN[key]; } // Fallback: grid-side bias (used by reverse playback where we don't // have the original key, only the letter + gridOffset). const gridBias = gridOffset === 1 ? 0.32 : -0.32; return Math.max(-0.9, Math.min(0.9, gridBias)); } // Push a drum flash entry so paint() will pulse the background in that // drum/weapon's characteristic color. Called from every kit trigger site // (keyboard act(), touch-tap, drag-rollover, reverse-playback replay). function flashDrum(letter, kit = "perc") { const table = isWarKit(kit) ? WAR_COLORS : PERCUSSION_COLORS; const color = table[letter] || [200, 200, 200]; drumFlashes.push({ color, frame, life: DRUM_FLASH_LIFE }); if (drumFlashes.length > 8) drumFlashes.shift(); } function makePercussionHold(letter, volume, pan, pitchFactor, voices = [], gridOffset = 0, baseVolume = volume) { return { letter, volume, pan, pitchFactor, voices, gridOffset, baseVolume, lastAppliedPitchFactor: pitchFactor, lastAppliedVolume: volume, ringUntil: 0, }; } function rememberRingingPercussionHold(hold, seconds) { if (!hold || !(seconds > 0)) return; const until = Date.now() + (seconds * 1000) + 80; hold.ringUntil = Math.max(hold.ringUntil || 0, until); if (!ringingPercussionHolds.includes(hold)) ringingPercussionHolds.push(hold); } function rememberPercussionHoldFromVoices(hold) { if (!hold?.voices?.length) return; let maxSeconds = 0; for (const entry of hold.voices) { maxSeconds = Math.max(maxSeconds, entry?.tailSeconds || 0); } rememberRingingPercussionHold(hold, maxSeconds); } function rememberPercussionReleaseTail(hold) { if (!hold?.voices?.length) return; let maxSeconds = 0; for (const entry of hold.voices) { if (entry?.handle && !entry?.ignoreRelease) { maxSeconds = Math.max(maxSeconds, entry.releaseFade ?? 0.08); } } rememberRingingPercussionHold(hold, maxSeconds); } // Release any held drum voices with their per-voice release fade. Each // entry is { handle, releaseFade, releaseUpdate?, onRelease? }. // - onRelease fires a standalone "release burst" synth call (e.g. the // subtle closed-hat lift click) before any update/kill runs. // - releaseUpdate shifts the voice's tone/volume before the final fade // (the "hi-hat pedal closing" brightness dampening effect). // - sound.kill() with the voice's fade time does the final ramp-out. function releasePercussionHold(sound, hold) { if (!hold?.voices?.length) return; rememberPercussionReleaseTail(hold); for (const entry of hold.voices) { if (entry?.onRelease) { try { entry.onRelease(); } catch {} } if (entry?.ignoreRelease) continue; if (!entry?.handle) continue; if (entry.releaseUpdate) { try { entry.handle.update?.(entry.releaseUpdate); } catch {} } sound?.kill?.(entry.handle, entry.releaseFade ?? 0.08); } } function triggerPercussionDown(sound, letter, octaveValue, volume, pan, pitchFactor, key, drumName, gridOffset = 0, baseVolume = volume) { const bankSample = wave === "sample" ? percussionSampleBank[drumName] : null; let hold; if (wave === "sample" && bankSample) { if (bankSample !== lastLoadedSample) { sound.sample.loadData(bankSample.data, bankSample.rate); lastLoadedSample = bankSample; } const handle = sound.sample.play({ tone: SAMPLE_BASE_FREQ * pitchFactor, base: SAMPLE_BASE_FREQ, volume, pan, loop: false, }); const voices = []; if (handle) { voices.push({ handle, ignoreRelease: true, releaseFade: 0, baseTone: SAMPLE_BASE_FREQ, baseVolumeUnit: 1.0, sampleBase: SAMPLE_BASE_FREQ, // Pitch can move down to 0.5x while the sample is already ringing, // so keep the live-update window conservative enough for stretched tails. tailSeconds: Math.max(0.05, ((bankSample?.len || 0) / Math.max(1, bankSample?.rate || 48000)) * 2.05), }); } hold = makePercussionHold(letter, volume, pan, pitchFactor, voices, gridOffset, baseVolume); } else { // Live-play: pass a holdVoices array that the kit synth populates // with sustain voices. These keep ringing until key-up calls // releasePercussionHold, which fades them out per-voice. Dispatch // to the active kit on this grid side. const kit = activeKitForOffset(gridOffset); const voices = []; if (isWarKit(kit)) { playWar(sound, letter, volume, pan, pitchFactor, "down", voices, warModelFor(kit)); } else if (kit === "zoo") { playZoo(sound, letter, volume, pan, pitchFactor, "down", voices); } else if (kit === "lasers") { playLaser(sound, letter, volume, pan, pitchFactor, "down", voices); } else { playPercussion(sound, letter, volume, pan, pitchFactor, "down", voices); } // Per-pad hit feedback — bright outline for ~12 frames (~200ms @ 60fps). const sideKey = (gridOffset === 0 ? "L_" : "R_") + letter; padHitUntil[sideKey] = frame + 12; hold = makePercussionHold(letter, volume, pan, pitchFactor, voices, gridOffset, baseVolume); hold.kit = kit; } rememberPercussionHoldFromVoices(hold); flashDrum(letter, hold?.kit || activeKitForOffset(gridOffset)); recordPlayback({ kind: "drum", letter, octave: octaveValue, vel: volume, pan, pitch: pitchFactor }); if (key) trail[key] = { note: letter, octave: octaveValue, brightness: 1.0 }; return hold; } // Fire a drum hit from short auto-stopping synth voices. `phase` controls // whether we emit only the strike (`down`), only the release/tail (`up`), // or the legacy combined hit (`both`, still used by reverse playback). // // RECIPES are grounded in classic analog drum synthesis (primarily Roland // TR-808) with frequencies and envelope shapes taken from the bridged-T // filter math in Kurt Werner's "Boom Like an 808" and the Faust // synths.lib reference (grame-cncm/faustlibraries). See reports/ for the // research summary. Key principle: the iconic 808 frequencies (238 Hz // snare, 540/800 Hz cowbell, 6-square hat cluster) are NOT jittered — // they're what makes each drum sound like itself. Per-hit variation // comes from TIMBRE jitter (volume balance, decay, attack, pan) not // tonal jitter. // // Multi-burst drums (clap, tambourine) use the Faust trick of firing // one voice per burst with staggered `attack` values (0.005 / 0.015 / // 0.025 / 0.035) — the audio engine's attack envelope provides the // sub-frame timing resolution that the setTimeout polyfill can't // (sim() only flushes every ~16 ms). // // Phase split: live keyboard/touch plays DOWN on key press and UP on // key release. Reverse playback uses `both` to replay complete drums. // Wrapper around the shared percussion kernel. Keeps the voice-inspector // overlay fed by using the `onVoice` callback — everything else is in // /lib/percussion.mjs now. function playPercussion(sound, letter, volume = 1.0, pan = 0, pitchFactor = 1.0, phase = "both", holdVoices = null) { if (!sound?.synth) return; const isLive = phase === "down" && Array.isArray(holdVoices); if (isLive || phase === "both") { drumInspectBuilder = { letter, name: PERCUSSION_NAMES[letter] || letter, voices: [], }; } sharedPlayPercussion(sound, letter, { volume, pan, pitchFactor, phase, holdVoices, onVoice: (voice) => { if (drumInspectBuilder) drumInspectBuilder.voices.push(voice); }, }); if (drumInspectBuilder && drumInspectBuilder.voices.length > 0) { lastDrumInspect = { ...drumInspectBuilder, until: frame + 300 }; } drumInspectBuilder = null; } // Fire a WAR-kit weapon through the native WAVE_GUN DWG synth. // All the physically-modeled parameters (bore length, body modes, // supersonic N-wave, retrigger cadence) live in audio.c's gun_presets[]. // This function just chooses between a finite one-shot and an infinite- // duration sustained voice, and pushes the right release bookkeeping // into `holdVoices` so releasePercussionHold can tear it down. // `model` selects which native synth path renders the weapon: // "default" → use the preset's hardcoded model (mixed) // "classic" → force 3-layer click+crack+boom+tail synthesis // "physical" → force DWG bore + body-mode resonance // The native side parses the suffix on the type string. function playWar(sound, letter, volume = 1.0, pan = 0, pitchFactor = 1.0, phase = "both", holdVoices = null, model = "default") { if (!sound?.synth) return; const preset = WAR_NAMES[letter]; if (!preset) return; // Resolve the actual model the C side will pick. For "default" the // physical-only weapons (grenade, rpg) render as physical; everything // else as classic. warB ("physical") forces the alt across the board. const resolvedModel = model === "physical" ? "physical" : (model === "classic" ? "classic" : (preset === "grenade" || preset === "rpg" ? "physical" : "classic")); if (phase !== "up") { lastWarInspect = { letter, name: preset, model: resolvedModel, params: WAR_PARAMS[preset]?.[resolvedModel] || null, until: frame + 300, }; } const v = Math.max(0.1, Math.min(2.2, volume)); // pitchFactor maps to the DWG's pressure scale rather than a pitch — // harder strikes → more combustion energy. Clamp to sane range. const pressure = Math.max(0.4, Math.min(1.8, pitchFactor)); const fireDown = phase !== "up"; if (!fireDown) return; const isLive = phase === "down" && Array.isArray(holdVoices); const sustained = !!WAR_SUSTAIN[letter]; const typeStr = model === "classic" || model === "physical" ? `gun-${preset}/${model}` : `gun-${preset}`; // Resolve which model the C side actually picks so we look up the // right override slot. (Mirrors the resolvedModel logic that updates // lastWarInspect above — keep them in sync.) const realModel = model === "physical" ? "physical" : (model === "classic" ? "classic" : (preset === "grenade" || preset === "rpg" ? "physical" : "classic")); const overrides = paramOverridesFor(preset, realModel); const hasOv = Object.keys(overrides).length > 0; if (sustained && isLive) { // Held weapon — infinite-duration voice killed on release. const opts = { type: typeStr, tone: pressure, // encoded as pressure multiplier (see js-bindings.c) duration: Infinity, volume: v, attack: 0, decay: 0, pan, }; if (hasOv) opts.params = overrides; const handle = sound.synth(opts); if (handle) { holdVoices.push({ handle, releaseFade: WAR_RELEASE[letter] ?? 0.2, tailSeconds: WAR_RELEASE[letter] ?? 0.5, }); } return; } // One-shot weapon — finite-duration voice. The native DWG envelope // generates the bang; `duration` just lets body-mode ring continue // for the weapon's characteristic tail before auto-kill. const duration = WAR_DURATION[letter] ?? 0.3; const opts = { type: typeStr, tone: pressure, duration, volume: v, attack: 0, decay: Math.max(0.02, duration * 0.7), pan, }; if (hasOv) opts.params = overrides; const handle = sound.synth(opts); if (isLive && handle) { // Track the voice so sim() can adjust pan/volume during its tail, // mirroring how addHit does for perc kit. holdVoices.push({ handle, ignoreRelease: true, releaseFade: 0, tailSeconds: duration, }); } } function flashPercussionNotice(text) { percussionNotice = { text, until: frame + 120 }; // ~2 seconds at 60fps } function currentAttack() { return ATTACK_VALUES[ATTACK_MODES[attackModeIdx]]; } function currentDecay() { return DECAY_VALUES[DECAY_MODES[decayModeIdx]]; } function flashEnvelopeNotice(param, mode) { // param: "attack" or "decay", mode: "zero"/"short"/"long" const icons = { zero: "·", short: "/", long: "~" }; const labels = { attack: "ATK", decay: "DEC" }; const text = `${labels[param]} ${icons[mode]} ${mode}`; envelopeNotice = { text, param, mode, until: frame + 90 }; // ~1.5s } function flashArrowNotice(text) { envelopeNotice = { text, param: "arrow", mode: "", until: frame + 90 }; } function flashGmNotice(text) { gmNotice = { text, until: frame + 150 }; // ~2.5s } // Accumulate a digit into the GM-program buffer and, on each press, resolve // the current buffer value to a selection. Menu Band semantics: a >0.8s gap // since the last digit starts a fresh buffer; the buffer caps at 3 digits. function selectGmDigit(digit, sound, system) { const now = Date.now(); if (now - gmDigitLastMs > 800) gmDigitBuffer = ""; gmDigitLastMs = now; if (gmDigitBuffer.length >= 3) gmDigitBuffer = ""; // roll over past 3 gmDigitBuffer += digit; const v = parseInt(gmDigitBuffer, 10); if (v === 0) { // "0" → MIDI passthrough toggle: relay only, no local synth. gmPassthrough = !gmPassthrough; gmProgram = null; flashGmNotice(gmPassthrough ? "MIDI PASSTHRU" : "MIDI LOCAL"); sound?.synth?.({ type: "sine", tone: gmPassthrough ? 990 : 440, duration: 0.06, volume: 0.14, attack: 0.002, decay: 0.05 }); return; } gmPassthrough = false; setGmProgram(Math.max(0, Math.min(127, v - 1)), sound, system); } // Commit a GM program: store it, flash its name, blip the new timbre, and // emit a MIDI Program Change over both transports (USB if the binding gains // programChange; UDP via the generic sendMidi event for the amxd plugin). function setGmProgram(program, sound, system) { gmProgram = program; const name = GM_PROGRAM_NAMES[program] || `Program ${program + 1}`; // jeffrey loves logs — surface the selection in the device log too. console.log(`[gm] select program ${String(program + 1).padStart(3, "0")} ${name}`); flashGmNotice(`${program + 1}. ${name}`); const recipe = gmRecipe(program); sound?.synth?.({ type: recipe.wave === "sample" ? "sine" : recipe.wave, tone: 523.25, duration: 0.12, volume: 0.18, attack: recipe.attack, decay: 0.1 }); // Program Change relay. usbMidi.programChange is optional (no-op until the // native binding adds it); UDP carries it as a generic event so the Max // device can switch patches. system?.usbMidi?.programChange?.(program, 0); sendUdpMidiEvent(system, "program_change", program, 0, 0); syncVoiceIndex(); // keep the global voice chooser in lockstep } function noteToFreq(note, oct) { const idx = CHROMATIC.indexOf(note); if (idx < 0) return 440; return 440 * Math.pow(2, (oct - 4) + (idx - 9) / 12); } function noteToMidiNumber(note, oct) { const idx = CHROMATIC.indexOf(note); if (idx < 0) return 60; return Math.max(0, Math.min(127, (oct + 1) * 12 + idx)); } function velocityToMidi(velocity) { const v = Number.isFinite(velocity) ? velocity : 1; return Math.max(1, Math.min(127, Math.round(v * 127))); } function midiNoteLabel(note, oct) { return note.toUpperCase() + oct; } function pushUsbMidiRecent(prefix, note, oct) { usbMidiRecent.unshift({ text: prefix + midiNoteLabel(note, oct), until: frame + 180 }); if (usbMidiRecent.length > 6) usbMidiRecent.length = 6; } function readUsbMidiStatus(system) { try { return system?.usbMidi?.status?.() || system?.usbMidi || { enabled: false, active: false, reason: "uninitialized" }; } catch (_) { return system?.usbMidi || { enabled: false, active: false, reason: "uninitialized" }; } } function activeUsbMidiNotes() { return Object.values(sounds) .filter((entry) => entry?.midiNote !== undefined) .sort((a, b) => (a.midiNote || 0) - (b.midiNote || 0)) .map((entry) => midiNoteLabel(entry.note, entry.octave)); } function usbMidiStatusText(status) { const notes = activeUsbMidiNotes(); const recent = usbMidiRecent .filter((entry) => entry.until > frame) .map((entry) => entry.text); if (status?.active) { if (notes.length > 0) return "USB MIDI ON " + notes.slice(0, 3).join(","); if (recent.length > 0) return "USB MIDI ON " + recent.slice(0, 2).join(" "); return "USB MIDI ON"; } return "USB MIDI OFF"; } function loadUdpMidiConfig(system) { // Default ON: every notepat install should broadcast to the amxd plugin // unless the user explicitly opts out by writing // {"udpMidiBroadcast": false} // into /mnt/config.json. Previously the flag defaulted to false and // required `true` to enable, which silently blocked the relay on every // OTA-updated device whose pre-existing config.json predates the flag. udpMidiBroadcast = true; try { const raw = system?.readFile?.("/mnt/config.json"); if (!raw) return; const cfg = JSON.parse(raw); if (cfg.udpMidiBroadcast === false || cfg.udpMidiBroadcast === "false") { udpMidiBroadcast = false; } } catch (_) { // Keep default (true) on parse failure. } } function sendUdpMidiEvent(system, event, midiNote, velocity, channel = 0) { if (!udpMidiBroadcast || !system?.udp?.connected) return; system?.udp?.sendMidi?.(event, midiNote, velocity, channel, "notepat"); udpMidiSentCount += 1; if (event === "note_off" || (event === "note_on" && velocity === 0)) { udpMidiOffCount += 1; } else { udpMidiOnCount += 1; } udpMidiLastPitch = midiNote; udpMidiLastVelocity = velocity; udpMidiLastSentFrame = frame; } function maybeSendUdpMidiHeartbeat(system) { if (!udpMidiBroadcast || !system?.udp?.connected) return; if (frame < udpMidiNextHeartbeatFrame) return; system?.udp?.sendMidiHeartbeat?.("notepat"); udpMidiNextHeartbeatFrame = frame + 300; } // Build the UDP-MIDI status line — parallel in form to usbMidiStatusText so // the two indicators read as siblings in the top bar. States: // "UDP MIDI OFF" broadcast disabled in /mnt/config.json // "UDP MIDI ..." broadcast enabled, socket not yet up // "UDP MIDI ON" socket up, no notes sent yet // "UDP MIDI ON 60v100 ▸ 42" socket up, last note + total-sent count // The caller colors based on whether a note was sent in the last ~1.5s // (bright green) vs idle-but-connected (dim green) vs disconnected (amber). function udpMidiRelayStatusText(system) { if (!udpMidiBroadcast) return "UDP MIDI OFF"; const connected = !!system?.udp?.connected; if (!connected) return "UDP MIDI ..."; if (udpMidiSentCount === 0) return "UDP MIDI ON"; const recent = frame - udpMidiLastSentFrame < 90; if (recent && udpMidiLastPitch >= 0) { return `UDP MIDI ON ${udpMidiLastPitch}v${udpMidiLastVelocity} \u25B8 ${udpMidiSentCount}`; } return `UDP MIDI ON \u25B8 ${udpMidiSentCount}`; } // Returns 0 when no note has ever been sent; otherwise 0..1 recency with // 1.0 at the moment of the send and fading to 0 over ~90 frames (1.5s). // Used to pulse the badge color from bright-green → dim-green as notes fire. function udpMidiSendRecency() { if (udpMidiSentCount === 0) return 0; const age = frame - udpMidiLastSentFrame; if (age < 0 || age > 90) return 0; return 1 - (age / 90); } function rememberSound(key, entry, system, velocity = 1) { if (!entry) return; // Capture linger intent at press time — shift may release before the key. // Menu Band semantics: a shift-held note does NOT latch forever; on key-up // it KEEPS sounding but FADES OUT over a few seconds (lingerTailSeconds=4 // for sustained families, ~1.2s for staccato). We flag lingerOnRelease so // stopSoundKey() takes the timed-fade path instead of the hard stop. // Enter-hold (heldKeys) remains a separate, permanent latch. if (entry.lingerCat === undefined) { entry.lingerCat = gmProgram !== null ? gmRecipe(gmProgram).linger : "sustain"; } // shiftHeld at press time arms the decaying linger tail (re-evaluated each // press so re-triggering the same key with shift up clears it). entry.lingerOnRelease = shiftHeld; entry.midiNote = noteToMidiNumber(entry.note, entry.octave); entry.midiChannel = 0; // Stash the captured velocity on the entry so sim() can blend it with // live pressure (55% velocity + 45% smoothed pressure). entry.velocity = velocity; sounds[key] = entry; // Only the Enter-hold latch permanently sustains on key-up. Shift uses the // decaying linger tail above, so it must NOT enter heldKeys. if (enterHeld) heldKeys.add(key); system?.usbMidi?.noteOn?.(entry.midiNote, velocityToMidi(velocity), entry.midiChannel); sendUdpMidiEvent(system, "note_on", entry.midiNote, velocityToMidi(velocity), entry.midiChannel); pushUsbMidiRecent(">", entry.note, entry.octave); } function stopSoundKey(key, sound, system, fade) { const entry = sounds[key]; if (!entry) return; // MIDI passthrough notes (gmPassthrough) synthesize nothing locally — just // relay the matching note_off(s) and drop the registry entry. if (entry.passthroughNotes) { for (const mn of entry.passthroughNotes) { system?.usbMidi?.noteOff?.(mn, 0, 0); sendUdpMidiEvent(system, "note_off", mn, 0, 0); } delete sounds[key]; return; } // Feature 4 — shift-held linger: instead of stopping now, let the voice(s) // ring out and fade to silence. Sustained families fade smoothly over ~4s; // staccato families get a shorter retrigger-style tail. MIDI note_off is // still sent immediately so downstream gear isn't left hanging. if (entry.lingerOnRelease) { const tail = entry.lingerCat === "staccato" ? 1.2 : 4.0; const list = entry.voices && entry.voices.length ? entry.voices : (entry.compositeVoices || [entry.synth || entry]); for (const voice of list) { if (!voice) continue; // kill(voice, tail) ramps the engine voice's fade 1→0 over `tail` // seconds (output is scaled by volume*fade), giving the linger // ring-out. Do NOT call update({volume:0}) first — update sets the // volume INSTANTLY (no ramp), muting the voice before the fade can // run, which is what made shift-linger cut off on key-release. sound?.kill?.(voice, tail); } lingerVoices.push({ until: frame + Math.ceil(tail * 60) + 30 }); if (entry.midiNote !== undefined) { system?.usbMidi?.noteOff?.(entry.midiNote, 0, entry.midiChannel || 0); sendUdpMidiEvent(system, "note_off", entry.midiNote, 0, entry.midiChannel || 0); pushUsbMidiRecent("<", entry.note, entry.octave); } if (entry.chordIvls) relayChordOff(entry, system); delete sounds[key]; return; } // Default fade to the user-selected decay mode (zero/short/long). For // sustained melodic notes the synth's own `decay` param never fires // because duration is infinite — the release envelope is controlled // here via the kill fade duration. if (fade === undefined) fade = currentDecay(); if (entry.voices && entry.voices.length > 1) { for (const voice of entry.voices) sound?.kill?.(voice, fade); } else if (entry.compositeVoices) { for (const voice of entry.compositeVoices) sound?.kill?.(voice, fade); } else { sound?.kill?.(entry.synth || entry, fade); } if (entry.midiNote !== undefined) { system?.usbMidi?.noteOff?.(entry.midiNote, 0, entry.midiChannel || 0); sendUdpMidiEvent(system, "note_off", entry.midiNote, 0, entry.midiChannel || 0); pushUsbMidiRecent("<", entry.note, entry.octave); } if (entry.chordIvls) relayChordOff(entry, system); delete sounds[key]; } // Relay note_off for the non-root chord tones (the root is sent via the // entry.midiNote path above; chordIvls always starts with 0 = root). function relayChordOff(entry, system) { const root = noteToMidiNumber(entry.note, entry.octave); for (const iv of entry.chordIvls) { if (iv === 0) continue; const mn = Math.max(0, Math.min(127, root + iv)); system?.usbMidi?.noteOff?.(mn, 0, 0); sendUdpMidiEvent(system, "note_off", mn, 0, 0); } } function releaseTouchNote(pid, sound, system, fade = 0.08) { const entry = touchNotes[pid]; if (!entry) return; if (entry.drumHold) { releasePercussionHold(sound, entry.drumHold); } else if (entry.key) { // Latched notes (shift-linger / Enter-hold) stay ringing on lift — match // the keyboard key-up behavior. if (!heldKeys.has(entry.key)) stopSoundKey(entry.key, sound, system, fade); } delete touchNotes[pid]; } function stopAllSounds(sound, system, fade = 0.08) { heldKeys.clear(); stopReversePlayback(sound); // Force a hard stop even on linger-flagged entries so a panic/exit is silent. for (const key of Object.keys(sounds)) { if (sounds[key]) sounds[key].lingerOnRelease = false; stopSoundKey(key, sound, system, fade); } activeDrumKeys = {}; ringingPercussionHolds = []; lingerVoices = []; touchNotes = {}; system?.usbMidi?.allNotesOff?.(0); sounds = {}; } function noteColor(n) { return NOTE_COLORS[n] || [80, 80, 80]; } // Hit-test a touch point against the note grid function hitTestGrid(x, y, gi) { const grids = [ { grid: LEFT_GRID, startX: gi.leftX, sideOffset: leftOctaveOffset }, { grid: RIGHT_GRID, startX: gi.rightX, sideOffset: rightOctaveOffset }, ]; for (const { grid, startX, sideOffset } of grids) { for (let r = 0; r < 3; r++) { for (let c = 0; c < 4; c++) { const bx = startX + c * (gi.btnW + gi.gap); const by = gi.gridTop + r * (gi.btnH + gi.gap); if (x >= bx && x < bx + gi.btnW && y >= by && y < by + gi.btnH) { const noteName = grid[r][c]; const key = NOTE_TO_KEY[noteName]; const [letter, off] = parseNote(noteName); return { key, letter, octave: octave + sideOffset, gridOffset: off }; } } } } return null; } // Play a short identifying blip in the new wave type when switching function playWaveSound(sound, waveType) { if (!sound?.synth) return; if (waveType === "sample") { // Short percussive click for sample mode. Previously this referenced // `pf` (a local from playZoo/playLaser/playPercussion that never made // it into this scope) — throwing a ReferenceError the moment anyone // switched wave to "sample". Plain tone is fine for a UI blip. sound.synth({ type: "noise", tone: 800, duration: 0.03, volume: 0.12, attack: 0.001, decay: 0.025, pan: 0 }); return; } const tones = { sine: 660, triangle: 550, sawtooth: 440, square: 330, piano: 440, harp: 440, whistle: 880 }; sound.synth({ type: waveType, tone: tones[waveType] || 440, duration: 0.07, volume: 0.18, attack: 0.002, decay: 0.06, pan: 0, }); } function stopSampleRecording(sound, reason = "stop") { if (!recording) return 0; recording = false; recPointerId = null; const len = sound?.microphone?.cut?.() || 0; sampleLoaded = len > 0; const mic = sound?.microphone || {}; const secs = (mic.sampleRate > 0 && len > 0) ? (len / mic.sampleRate).toFixed(2) : "0.00"; console.log(`[sample] ${reason}: len=${len} rate=${mic.sampleRate || 0} secs=${secs} err=${mic.lastError || ""}`); return len; } function setWave(nextWave, sound, { silent = false } = {}) { if (!nextWave) return; // Picking a basic wave (sine/triangle/.../sample) reroutes OFF any GM // instrument — the GM program overrides `wave` when set, so clear it so // the chosen wave actually sounds. if (gmProgram !== null || gmPassthrough) { gmProgram = null; gmPassthrough = false; gmDigitBuffer = ""; console.log("[gm] cleared — switched to wave " + nextWave); } if (wave === nextWave) return; const prev = wave; if (prev === "sample" && recording) stopSampleRecording(sound, "wave-exit"); wave = nextWave; waveIndex = wavetypes.indexOf(nextWave); if (waveIndex < 0) waveIndex = 0; // Announce wave type (skipped when silent — e.g. Home jumps in and records // immediately, so a spoken "sample" or blip would land in the take). if (!silent) sound?.speak?.(nextWave); if (wave === "sample") { const mic = sound?.microphone || {}; sampleLoaded = (mic.sampleLength || 0) > 0; // Open hot-mic so device stays ready — recording is instant after this. // Always call open(); C side is idempotent if already hot. sound?.microphone?.open?.(); if (!silent) playWaveSound(sound, wave); console.log(`[sample] wave-enter: loaded=${sampleLoaded} len=${mic.sampleLength || 0} rate=${mic.sampleRate || 0} connected=${!!mic.connected} hot=${!!mic.hot} device=${mic.device || "none"} err=${mic.lastError || ""}`); } else { // Close hot-mic when leaving sample mode to free the device if (prev === "sample") sound?.microphone?.close?.(); if (!silent) playWaveSound(sound, wave); } syncVoiceIndex(); // keep the global voice chooser in lockstep } function scheduleAutoUpdateCheck(delayFrames = 0) { autoUpdate.state = "checking"; autoUpdate.fetchPending = false; autoUpdate.nextCheckFrame = frame + Math.max(0, delayFrames | 0); } // Version format is "-YYYY-MM-DDTHH:MM". // We only treat remote as update when its timestamp is strictly newer. function parseVersionStamp(version) { const raw = (version || "").trim(); if (!raw) return { raw: "", stamp: "", ms: NaN }; const m = raw.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})$/); if (!m) return { raw, stamp: "", ms: NaN }; const stamp = m[1]; const ms = Date.parse(stamp + ":00Z"); return { raw, stamp, ms }; } function isRemoteVersionNewer(remoteVersion, localVersion) { const remote = parseVersionStamp(remoteVersion); const local = parseVersionStamp(localVersion); if (!isNaN(remote.ms) && !isNaN(local.ms)) return remote.ms > local.ms; if (!isNaN(remote.ms) && isNaN(local.ms)) return true; return false; // conservative: unknown formats do not trigger "update available" } function notifyUpdateAvailable(sound, version) { if (!version || osUpdatePingVersion === version) return; osUpdatePingVersion = version; sound?.synth?.({ type: "sine", tone: 988, duration: 0.08, volume: 0.16, attack: 0.002, decay: 0.06 }); sound?.synth?.({ type: "sine", tone: 1319, duration: 0.12, volume: 0.14, attack: 0.01, decay: 0.08 }); sound?.speak?.("system update available"); } // (Removed startAutoUpdateDownload — the background path no longer downloads // or flashes; updates are user-initiated only, through the manual OS panel.) function boot({ wipe, system, sound }) { wipe(0); soundAPI = sound; systemAPI = system; sound?.room?.setMix?.(echoMix); sound?.glitch?.setMix?.(bitcrushMix); sound?.fx?.setMix?.(fxMix); sound?.volume?.setMix?.(masterVolMix * 2); // slider 0..1 → audio 0..2 sound?.drive?.setMix?.(driveMix); sound?.wobble?.setMix?.(wobbleMix); loadUdpMidiConfig(system); udpMidiNextHeartbeatFrame = 0; // Anchor the global voice chooser to the current voice so its first open // shows the right entry (default wave=sine → AC slot, not GM #1). syncVoiceIndex(); voiceScroll = voiceIndex; const mic = sound?.microphone || null; if (mic && (mic.sampleLength || 0) > 0) { sampleLoaded = true; console.log(`[sample] boot: preloaded len=${mic.sampleLength} rate=${mic.sampleRate || 0} device=${mic.device || "none"}`); } // Load saved credentials from USB EFI partition if (system?.readFile) { try { const raw = system.readFile(CREDS_PATH); if (raw) savedCreds = JSON.parse(raw); } catch (_) {} } // Capture current version for OS panel osCurrentVersion = system?.version || "unknown"; // Mount music USB + scan for deck tracks. If a track is already loaded // in the global deck (e.g. resumed from dj.mjs), leave it be; otherwise // auto-load the first discovered track so users can jam immediately. system?.mountMusic?.(); djMounted = !!system?.mountMusicMounted; djUsbConnected = djMounted; djScan(system, sound); const dk0 = sound?.deck?.decks?.[0]; if (dk0?.loaded) { djMsg("resumed"); } else if (djFiles.length > 0) { djLoadTrack(sound); } } function act({ event: e, sound, wifi, system }) { soundAPI = sound; systemAPI = system; // Track shift state before any other handling. Tiny audible click on // press/release so the mode change is felt — high blip going in, low // blip coming out, both quiet enough to layer under any active note. if (e.is("keyboard:down") && e.key?.toLowerCase() === "shift" && !shiftHeld) { shiftHeld = true; sound?.synth?.({ type: "sine", tone: 1320, duration: 0.025, volume: 0.10, attack: 0.001, decay: 0.022, pan: 0 }); } else if (e.is("keyboard:up") && e.key?.toLowerCase() === "shift" && shiftHeld) { shiftHeld = false; sound?.synth?.({ type: "sine", tone: 880, duration: 0.025, volume: 0.10, attack: 0.001, decay: 0.022, pan: 0 }); } // Chord modifiers — track control/alt the same way as shift. Held while a // note key fires, they turn the note into a triad (see chord block below). if (e.is("keyboard:down") && e.key?.toLowerCase() === "control") ctrlHeld = true; else if (e.is("keyboard:up") && e.key?.toLowerCase() === "control") ctrlHeld = false; if (e.is("keyboard:down") && e.key?.toLowerCase() === "alt") altHeld = true; else if (e.is("keyboard:up") && e.key?.toLowerCase() === "alt") altHeld = false; // WiFi password input mode — fullscreen, capture all keyboard if (wifiPasswordMode && e.is("keyboard:down")) { const key = e.key?.toLowerCase(); if (key === "escape") { wifiPasswordMode = false; wifiPassword = ""; wifiSelectedIdx = -1; return; } if (key === "enter") { if (wifi && wifiSelectedIdx >= 0) { const nets = wifi.networks || []; const net = nets[wifiSelectedIdx]; if (net) { wifi.connect(net.ssid, wifiPassword); // Save credentials persistently (skip duplicates) const already = savedCreds.find((c) => c.ssid === net.ssid); if (!already) savedCreds.push({ ssid: net.ssid, pass: wifiPassword }); else already.pass = wifiPassword; } } wifiPasswordMode = false; activeScreen = "notepat"; wifiSelectedIdx = -1; return; } if (key === "backspace") { wifiPassword = wifiPassword.slice(0, -1); return; } if (key.length === 1) { wifiPassword += shiftHeld ? (SHIFT_MAP[key] ?? key.toUpperCase()) : key; return; } return; } // WiFi password mode — block touch from reaching note grid if (wifiPasswordMode && (e.is("touch") || e.is("draw") || e.is("lift"))) { return; } // === Gun inspector drag-to-edit === // Tap a param card → start editing. Drag vertically → change value // (log-scaled for freq/duration, linear for amps/Q). Lift → release. // Hits intercept the touch so it doesn't also trigger a note pad press. if (e.is("touch") && warInspectCardRects && !editingWarCard) { const tx = e.pointer?.x ?? e.x ?? -1; const ty = e.pointer?.y ?? e.y ?? -1; for (const r of warInspectCardRects) { if (tx >= r.x && tx < r.x + r.w && ty >= r.y && ty < r.y + r.h) { const startVal = paramValue(r.weapon, r.model, r.key); editingWarCard = { ...r, startVal, startY: ty, lastVal: startVal }; if (lastWarInspect) lastWarInspect.until = frame + 300; // keep visible return; } } } if (e.is("draw") && editingWarCard) { const ty = e.pointer?.y ?? e.y ?? editingWarCard.startY; const dy = editingWarCard.startY - ty; // up = positive const range = WAR_PARAM_RANGES[editingWarCard.key]; if (range) { const [lo, hi, scale] = range; // 100 px of vertical drag traverses the full range. const t = Math.max(-1, Math.min(1, dy / 100)); let baseT; if (scale === "log") { const logLo = Math.log(Math.max(0.0001, lo)); const logHi = Math.log(hi); baseT = (Math.log(Math.max(0.0001, editingWarCard.startVal)) - logLo) / (logHi - logLo); } else { baseT = (editingWarCard.startVal - lo) / (hi - lo); } let newT = Math.max(0, Math.min(1, baseT + t)); let newVal; if (scale === "log") { const logLo = Math.log(Math.max(0.0001, lo)); const logHi = Math.log(hi); newVal = Math.exp(logLo + newT * (logHi - logLo)); } else { newVal = lo + newT * (hi - lo); } const ovKey = `${editingWarCard.weapon}/${editingWarCard.model}/${editingWarCard.key}`; WAR_PARAM_OVERRIDES[ovKey] = newVal; editingWarCard.lastVal = newVal; if (lastWarInspect) lastWarInspect.until = frame + 300; } return; } if (e.is("lift") && editingWarCard) { editingWarCard = null; return; } if (e.is("keyboard:down")) { const key = e.key?.toLowerCase(); if (!key) return; // Track the most recent physical key for the top status-bar label. // Shown in the status bar via statusWrite() when there's space. lastKeyPressed = key; lastKeyFrame = frame; // Note: we do NOT alias media-key scancodes (audiomute, audiovolumedown, // brightnessdown, etc.) to F-keys anymore. Different laptops place those // media functions on different top-row positions (e.g. X1 Nano puts mute // on F1 and volume-down on F2), so aliasing them to f10/f11/f12 caused // F1/F2 to incorrectly trigger hold/latch. Users can still reach // F1-F12 by holding Fn. If a specific machine needs bare-key access we'll // remap in input.c, not here. // === DJ DECK KEYS === // F1 play/pause, F2 next, F3 prev, F4 scan+mount USB. Also [ = speed−, // ` = reset speed. Tap tempo on `,` (unused elsewhere in notepat). if (key === "f1") { djTogglePlay(sound); return; } if (key === "f2") { djTrackIdx++; djLoadTrack(sound); return; } if (key === "f3") { djTrackIdx = (djTrackIdx - 1 + Math.max(1, djFiles.length)) % Math.max(1, djFiles.length); djLoadTrack(sound); return; } if (key === "f4") { system?.mountMusic?.(); djMounted = !!system?.mountMusicMounted; djUsbConnected = djMounted; if (djMounted) { djScan(system, sound); if (djFiles.length > 0) { djTrackIdx = 0; djLoadTrack(sound); } } else { djMsg(system?.mountMusicPending ? "checking usb" : "no usb"); } return; } // F5 — cycle internet radio stations (KPBJ, r8dio, NTS 1/2) through // the shared deck. Layer notepat melody over a live feed. if (key === "f5") { djTuneRadio(sound, radioIdx + 1); return; } if (key === "[") { const d = sound?.deck?.decks?.[0]; if (d?.loaded) { const s = Math.max(-2, (d.speed || 1) - 0.05); sound.deck.setSpeed(0, s); djMsg(`${s.toFixed(2)}x`); } return; } if (key === "`") { if (sound?.deck?.decks?.[0]?.loaded) { sound.deck.setSpeed(0, 1); djMsg("1.00x"); } return; } // Octave shortcuts (Menu Band parity). `,` / `<` = octave down, // `.` / `>` = octave up, clamped 0..8. (Native input.c never emits the // shifted `<`/`>` glyphs — it sends `,`/`.` with shiftHeld set — so the // base keys already cover both; the `<`/`>` aliases are defensive.) if (key === "," || key === "<") { octave = Math.max(0, octave - 1); flashArrowNotice(`OCT ${octave}`); sound?.synth?.({ type: "sine", tone: 330, duration: 0.05, volume: 0.12, attack: 0.002, decay: 0.04 }); return; } if (key === "." || key === ">") { octave = Math.min(8, octave + 1); flashArrowNotice(`OCT ${octave}`); sound?.synth?.({ type: "sine", tone: 660, duration: 0.05, volume: 0.12, attack: 0.002, decay: 0.04 }); return; } // Attack/decay envelope cycling moved here off `,`/`.` (which now do // octave). `/` cycles attack; shift+`/` cycles decay. if (key === "/") { if (shiftHeld) { decayModeIdx = (decayModeIdx + 1) % DECAY_MODES.length; flashEnvelopeNotice("decay", DECAY_MODES[decayModeIdx]); } else { attackModeIdx = (attackModeIdx + 1) % ATTACK_MODES.length; flashEnvelopeNotice("attack", ATTACK_MODES[attackModeIdx]); } return; } if (key === "escape" && activeScreen === "wifi") { activeScreen = "notepat"; return; } if (key === "escape" && activeScreen === "os") { activeScreen = "notepat"; osState = "idle"; return; } if (key === "escape" && activeScreen === "notepat") { // Triple-escape to exit: 3 presses within 90 frames (~1.5s) if (frame - escLastFrame > 90) escCount = 0; escCount++; escLastFrame = frame; if (escCount === 1) { // First escape: low warning tone + spoken count const beep = sound?.synth?.("sine", { tone: 220, duration: 0.15, volume: 0.18, attack: 0.005, decay: 0.14 }); if (beep) setTimeout(() => sound?.kill?.(beep, 0.02), 150); sound?.speak?.("one"); return; } if (escCount === 2) { // Second escape: rising warning, two notes const a = sound?.synth?.("sine", { tone: 330, duration: 0.1, volume: 0.2, attack: 0.005, decay: 0.09 }); if (a) setTimeout(() => sound?.kill?.(a, 0.02), 100); setTimeout(() => { const b = sound?.synth?.("sine", { tone: 440, duration: 0.1, volume: 0.2, attack: 0.005, decay: 0.09 }); if (b) setTimeout(() => sound?.kill?.(b, 0.02), 100); }, 80); sound?.speak?.("two"); return; } // Third escape — descending exit chord + spoken "exit" const c1 = sound?.synth?.("sine", { tone: 660, duration: 0.12, volume: 0.22, attack: 0.005, decay: 0.11 }); if (c1) setTimeout(() => sound?.kill?.(c1, 0.02), 120); setTimeout(() => { const c2 = sound?.synth?.("sine", { tone: 523, duration: 0.12, volume: 0.22, attack: 0.005, decay: 0.11 }); if (c2) setTimeout(() => sound?.kill?.(c2, 0.02), 120); }, 80); setTimeout(() => { const c3 = sound?.synth?.("sine", { tone: 392, duration: 0.18, volume: 0.25, attack: 0.005, decay: 0.17 }); if (c3) setTimeout(() => sound?.kill?.(c3, 0.02), 180); }, 160); sound?.speak?.("exit"); escCount = 0; stopAllSounds(sound, system, 0.05); system?.jump?.("prompt"); return; } // Shift key: LIVE-HELD accent modifier (was: quick mode toggle). // shiftHeld is already tracked at the top of act(); note/drum triggers // check it to boost velocity so capitalized/shifted playing feels // like accenting. The old quick-mode toggle is deprecated — tap // shift does nothing on its own now. if (key === "shift") return; if (key === "tab") { const idx = (waveIndex + 1) % wavetypes.length; setWave(wavetypes[idx], sound); return; } if (key === "space") { // Space = true reverse replay from the recent audio buffer. if (!spaceHeld) { spaceHeld = true; // Freeze the output-history ring while reversing. Anything played // on top during the hold is heard but NOT captured, so letting go // of space resumes recording from exactly where reversing started // instead of snapshotting the reverse echo back into the buffer. sound?.speaker?.setCapturePaused?.(true); // Lock the visual cursor's start time to now — the strip will // drive waveViewOffsetSec directly from elapsed-since-press so // the needle is perfectly aligned with whatever the reverse // playback voice is currently emitting. spacePressStartMs = Date.now(); if (!startReversePlayback(sound) && sound && sound.synth) { // Fallback kick drum when there's no recent audio to reverse. sound.synth({ type: "sine", tone: 150, duration: 0.15, volume: 0.9, attack: 0.001, decay: 0.14, pan: 0.0 }); sound.synth({ type: "sine", tone: 60, duration: 0.2, volume: 0.7, attack: 0.001, decay: 0.19, pan: 0.0 }); } } return; } // Enter: "hold modifier" — while physically held, note keys pressed // auto-latch into heldKeys. Also immediately latches any currently- // playing notes so the existing chord sustains as soon as Enter // engages. Release Enter and the latched notes keep ringing. if (key === "enter") { if (!enterHeld) { enterHeld = true; // Snapshot anything playing right now into heldKeys for (const k of Object.keys(sounds)) heldKeys.add(k); // Engage cue: ascending pair sound?.synth?.({ type: "sine", tone: 660, duration: 0.06, volume: 0.14, attack: 0.002, decay: 0.05 }); sound?.synth?.({ type: "sine", tone: 990, duration: 0.08, volume: 0.10, attack: 0.005, decay: 0.07 }); } return; } // Backspace: "clear holds" — stop all held notes if (key === "backspace") { if (heldKeys.size > 0) { for (const k of heldKeys) stopSoundKey(k, sound, system, 0.08); heldKeys.clear(); sound?.synth?.({ type: "sine", tone: 330, duration: 0.06, volume: 0.12, attack: 0.002, decay: 0.05 }); } return; } // F9: metronome toggle (no Fn key required) if (key === "f9") { metronomeEnabled = !metronomeEnabled; if (metronomeEnabled) { metronomeBeatCount = Math.floor(syncedNow() / (60000 / metronomeBPM)) - 1; } return; } // PgUp: cycle LEFT grid kit (off → perc → war → off). // PgDn: same for RIGHT grid. Each cycle fires a sample of the new // kit ("c" = kick for perc, pistol for war) so the sound confirms // which mode you just entered. if (key === "pageup" || key === "pagedown") { const side = key === "pageup" ? "left" : "right"; const arrow = side === "left" ? "◀" : "▶"; const pan = side === "left" ? -0.4 : 0.4; const feedbackPan = side === "left" ? -0.6 : 0.6; const next = cycleKit(side); const label = `${side} ${next === "off" ? "notes" : next}`; const banner = { off: side === "left" ? `${arrow} notes` : `notes ${arrow}`, perc: side === "left" ? `${arrow} DRUMS` : `DRUMS ${arrow}`, warA: side === "left" ? `${arrow} WAR A` : `WAR A ${arrow}`, warB: side === "left" ? `${arrow} WAR B` : `WAR B ${arrow}`, zoo: side === "left" ? `${arrow} ZOO` : `ZOO ${arrow}`, lasers: side === "left" ? `${arrow} LASERS` : `LASERS ${arrow}`, }[next]; flashPercussionNotice(banner); sound?.speak?.(label); // Two-tone UI feedback — rising when enabling, falling when off. const isOn = next !== "off"; sound?.synth?.({ type: "triangle", tone: isOn ? 440 : 660, duration: 0.08, volume: 0.18, attack: 0.002, decay: 0.07, pan: feedbackPan }); setTimeout(() => sound?.synth?.({ type: "triangle", tone: isOn ? 660 : 440, duration: 0.10, volume: 0.18, attack: 0.002, decay: 0.09, pan: feedbackPan, }), 70); // Preview the kit's signature sound on "c" so the user hears what // they just switched to. if (next === "perc") playPercussion(sound, "c", 1.6, pan); else if (isWarKit(next)) playWar(sound, "c", 1.2, pan, 1.0, "both", null, warModelFor(next)); else if (next === "zoo") playZoo(sound, "c", 1.2, pan); else if (next === "lasers") playLaser(sound, "c", 1.2, pan); return; } // F12 (star key): recital mode — hide UI, show only colored backdrops if (key === "f12") { recitalMode = !recitalMode; sound?.synth?.({ type: "sine", tone: recitalMode ? 880 : 440, duration: 0.12, volume: 0.18, attack: 0.005, decay: 0.11 }); return; } // Meta/Win key: toggle keyboard shortcut help panel if (key === "meta") { helpPanel = !helpPanel; sound?.synth?.({ type: "sine", tone: helpPanel ? 660 : 440, duration: 0.08, volume: 0.15, attack: 0.005, decay: 0.07 }); return; } // Number keys 0-9 → General MIDI program selection (Menu Band semantics). // Digits accumulate into a buffer (max 3); a >0.8s gap starts fresh. // Value v: v==0 toggles MIDI passthrough (relay only, no local synth); // v>=1 selects GM program (v-1), clamped 0..127. So "1"→Acoustic Grand, // "128"→Gunshot. Only meaningful while a side is melodic — when both // sides are full kits there's no GM layer to target, so digits no-op. // Octave is now driven by `,`/`.`, not the number row. if (key >= "0" && key <= "9") { if (!anyMelodicSide()) return; selectGmDigit(key, sound, system); return; } // Left/Right arrows now drive the GLOBAL VOICE CHOOSER — the single // horizontal strip of all 128 GM programs followed by the 8 AC // wavetypes. Each press steps the selection by one entry (clamped at // both ends) and applies it through the existing setGmProgram/setWave // routes, so it stays in lockstep with the number-row digit picker and // the wave-button row. (Up/Down still adjust master volume below.) if (key === "arrowleft") { stepVoice(-1, sound, system); return; } if (key === "arrowright") { stepVoice(+1, sound, system); return; } // Up/Down: master volume. Side-select used to ride Left/Right; now that // those drive the voice chooser, Up/Down nudge whichever side is armed // (arrowSelectedSide, still toggled by tapping a grid's master slider). if (key === "arrowup") { if (arrowSelectedSide === 0) { leftMasterVol = Math.min(1.5, leftMasterVol + 0.1); flashArrowNotice(`L vol ${Math.round(leftMasterVol * 100)}%`); } else { rightMasterVol = Math.min(1.5, rightMasterVol + 0.1); flashArrowNotice(`R vol ${Math.round(rightMasterVol * 100)}%`); } return; } if (key === "arrowdown") { if (arrowSelectedSide === 0) { leftMasterVol = Math.max(0, leftMasterVol - 0.1); flashArrowNotice(`L vol ${Math.round(leftMasterVol * 100)}%`); } else { rightMasterVol = Math.max(0, rightMasterVol - 0.1); flashArrowNotice(`R vol ${Math.round(rightMasterVol * 100)}%`); } return; } // Home key: jump straight into sample mode (silently — recording starts // on this same press, so NO click/speak that would land in the take), // clear all custom per-key samples, and record a fresh GLOBAL sample // while held. if (key === "home" && !recording && !perKeyRecording) { if (wave !== "sample") setWave("sample", sound, { silent: true }); sampleBank = {}; // clear custom per-key samples lastLoadedSample = null; // force reload on next play const ok = !!sound?.microphone?.rec?.(); recording = ok; recPointerId = null; if (ok) recStartTime = Date.now(); console.log(`[mic] rec-home: ok=${ok} (cleared per-key bank)`); return; } // End key: jump into sample mode and arm per-key recording. A click is OK // here — End is pressed BEFORE the note/record key, so it won't be // captured (unlike Home, which records on its own press). if (key === "end") { if (wave !== "sample") setWave("sample", sound, { silent: true }); endArmed = true; sound?.synth?.({ type: "square", tone: 1760, duration: 0.012, volume: 0.4, attack: 0.0004, decay: 0.01 }); console.log(`[sample-bank] armed for per-key recording`); return; } // Delete key: clear all per-key samples, reset to global/default if (key === "delete" && wave === "sample") { sampleBank = {}; console.log(`[sample-bank] cleared all per-key samples`); // Restore global sample if we have one if (globalSample) { sound.sample.loadData(globalSample.data, globalSample.rate); sampleLoaded = true; } // Clean descending triangle blip for the clear confirmation. The // previous "noise" voice here referenced `pf` (pitch factor, only // defined inside playZoo/playPercussion scopes) so it threw a // ReferenceError every time Delete was pressed in sample mode, and // even when the error didn't trip it was just a hissy sample-rate // smear that didn't fit the sample-mode palette. sound.synth({ type: "triangle", tone: 440, duration: 0.06, volume: 0.18, attack: 0.002, decay: 0.05 }); sound.synth({ type: "triangle", tone: 220, duration: 0.09, volume: 0.14, attack: 0.02, decay: 0.07 }); return; } // Arrow left/right handled above (octave per side) if (key === "\\") { trackpadFX = !trackpadFX; sound.synth({ type: "sine", tone: trackpadFX ? 880 : 440, duration: 0.06, volume: 0.2, attack: 0.002, decay: 0.05, pan: 0, }); // FX on: bright chord; FX off: hollow low note if (trackpadFX) sound?.synth({ type: "triangle", tone: 1320, duration: 0.08, volume: 0.15, attack: 0.002, decay: 0.06 }); return; } if (key === "-") { metronomeBPM = Math.max(20, metronomeBPM - 5); metronomeBeatCount = Math.floor(syncedNow() / (60000 / metronomeBPM)) - 1; return; } if (key === "=") { metronomeBPM = Math.min(300, metronomeBPM + 5); metronomeBeatCount = Math.floor(syncedNow() / (60000 / metronomeBPM)) - 1; return; } const noteName = KEY_TO_NOTE[key]; // Per-key recording: End armed + tone key = record to that key if (noteName && endArmed && wave === "sample" && !perKeyRecording && !recording) { const ok = !!sound?.microphone?.rec?.(); if (ok) { perKeyRecording = key; recStartTime = Date.now(); console.log(`[sample-bank] recording to key '${key}' (${noteName})`); } return; // Suppress note playback while recording } // If this key is currently recording in per-key mode, suppress playback if (perKeyRecording === key) return; // Latch toggle-off: a note that's currently LATCHED (shift-linger or // Enter-hold) is still ringing in `sounds[key]`. Pressing its key again // un-latches and stops it — the intuitive "press again to release" gesture // for held notes. We force a hard stop (clear lingerOnRelease) so it // doesn't re-enter a fade tail, and align with the existing heldKeys set. if (noteName && sounds[key] && heldKeys.has(key)) { heldKeys.delete(key); if (sounds[key]) sounds[key].lingerOnRelease = false; stopSoundKey(key, sound, system, quickMode ? 0.02 : 0.08); return; } if (noteName && !sounds[key]) { // Re-arm the audio-history ring before this note hits speakers. // Capture stays paused after a space-release so the ring doesn't // record the silent gap; unpausing here means the next reverse // gesture replays exactly the audio the user generated, with // none of the real-time silence between presses cluttering it. // Idempotent — safe to call when capture is already running. sound?.speaker?.setCapturePaused?.(false); // NuPhy detection: analog pressure arrives as a float strictly between // 0 and 1 (digital keys get the default 1.0 stamped on). Refresh the // "last analog keypress" timestamp whenever we see one so the status // bar badge + pressure-diagram overlay stay live. if (e.pressure > 0 && e.pressure < 0.999) { lastAnalogKeyAt = syncedNow(); lastAnalogPressure = e.pressure; } const [letter, offset] = parseNote(noteName); // Map parseNote offset to per-side octave: offset<=0 = left grid, offset>=1 = right grid const sideOctaveAdj = offset >= 1 ? rightOctaveOffset : leftOctaveOffset; const noteOctave = octave + sideOctaveAdj + (offset >= 2 ? offset - 1 : offset <= -1 ? offset : 0); const freq = noteToFreq(letter, noteOctave); const semitones = (noteOctave - 4) * 12 + CHROMATIC.indexOf(letter); const pan = Math.max(-0.8, Math.min(0.8, (semitones - 12) / 15)); // Start at current pressure (or full for digital keys) // Velocity starts at analog pressure (or full for digital keys). // Shift-as-accent: when shiftHeld, boost velocity by 1.4× (capped // at 1.0 so we don't exceed the engine's expected range for notes; // drums will scale their volume separately via the accent path below). let velocity = e.pressure > 0 ? e.pressure : 1.0; if (shiftHeld) velocity = Math.min(1.0, velocity * 1.4); // Per-side master mix — scales every note and drum on this grid. const master = masterForSide(offset); const baseVol = 0.15 + velocity * 0.55; // un-mastered; sim() re-applies master live const vol = baseVol * master; const playFreq = freq * Math.pow(2, effectivePitchShift()); // === PERCUSSION MODE === // If this grid side has percussion enabled, play a drum hit instead // of a melodic tone. Drums are one-shots so we don't register them // in `sounds[key]`; the visual flash is driven purely by `trail`. const drumName = percussionDrumFor(letter, offset); if (drumName) { if (activeDrumKeys[key]) return; // Per-drum sampling: End armed + drum key = record to this drum slot. if (wave === "sample" && endArmed && !perKeyRecording && !recording) { const ok = !!sound?.microphone?.rec?.(); if (ok) { perKeyRecording = key; recStartTime = Date.now(); console.log(`[perc-bank] recording to drum '${drumName}' (key '${key}')`); } return; } if (perKeyRecording === key) return; // Push drums well above unity so they cut through the mix. The // mixer divides by total voice weight, so at velocity 1.0 we want // a drum-layer peak near ~1.8 so that against 3 sustained // melodic voices (~2.1 weight) the kick still lands at ~0.5 vs // each melody at ~0.17 — roughly 3× prominence. Then scale the // whole thing by the per-side master so the user can balance L/R. // Base drum volume from velocity, then apply shift-accent boost // (shift-held = capitalized = accented hit). Drums get more // aggressive accent than notes since the compressor will rein // them in anyway. const accent = shiftHeld ? 1.5 : 1.0; const baseDrumVol = (1.0 + velocity * 0.8) * accent; const drumVol = baseDrumVol * master; // Subtle note-based pitch harmonization: each semitone nudges the // drum's fundamental by 1/36 of an octave (one full keyboard // octave = a minor third of pitch shift). A kick on C3 sounds // noticeably lower than a kick on C5, and a snare on D harmonizes // just a hair above a kick on C. Combined with the trackpad // pitch shift via effectivePitchShift(). const drumPitch = Math.pow(2, effectivePitchShift() + semitones / 36); // Drums get their own pan (kit geometry + grid bias), not the // QWERTY physical key position → pan. Left keys pan left, right pan right. const drumPan = drumPanFor(letter, offset, key); activeDrumKeys[key] = triggerPercussionDown( sound, letter, noteOctave, drumVol, drumPan, drumPitch, key, drumName, offset, baseDrumVol ); trail[key] = { note: letter, octave: noteOctave, brightness: velocity }; return; } // This grid side is melodic (the drum branch above returned for any // active kit), so the GM instrument layer + chord modifiers apply. // Chord triad intervals from the held modifier (Menu Band parity): // control = major (0,4,7), alt = minor (0,3,7), // control+alt = sus2 (0,2,7). No modifier = single note. const chordIvls = ctrlHeld && altHeld ? [0, 2, 7] : ctrlHeld ? [0, 4, 7] : altHeld ? [0, 3, 7] : null; // Linger intent captured at press time (shift may release first). const lingerOnRelease = shiftHeld; // MIDI passthrough: relay note(s) only, synthesize nothing locally. if (gmPassthrough) { const tones = chordIvls || [0]; const voices = []; // empty — nothing to kill, but keeps the registry shape const midiNotes = tones.map((iv) => Math.max(0, Math.min(127, noteToMidiNumber(letter, noteOctave) + iv))); for (const mn of midiNotes) { system?.usbMidi?.noteOn?.(mn, velocityToMidi(velocity), 0); sendUdpMidiEvent(system, "note_on", mn, velocityToMidi(velocity), 0); } sounds[key] = { passthroughNotes: midiNotes, voices, note: letter, octave: noteOctave, gridOffset: offset, lingerOnRelease: false }; // Only Enter-hold permanently latches passthrough notes. Shift no // longer latches (it drives a decaying fade tail, but passthrough has // no local voice to fade — it just relays note_off on release). if (enterHeld) heldKeys.add(key); trail[key] = { note: letter, octave: noteOctave, brightness: velocity }; return; } // GM program selected → synth via the family recipe (overrides `wave`) // for a single note OR a chord. Each chord tone is its own voice; we // store every voice id on sounds[key].voices so release kills them all. if (gmProgram !== null || chordIvls) { const recipe = gmProgram !== null ? gmRecipe(gmProgram) : null; const useWave = recipe ? recipe.wave : (wave === "sample" ? "sine" : wave); const atk = recipe ? recipe.attack : (quickMode ? 0.002 : currentAttack()); const recipeVol = recipe ? recipe.volume : 1.0; const ivls = chordIvls || [0]; const voices = []; for (const iv of ivls) { const cf = playFreq * Math.pow(2, iv / 12); // Spread voices a touch in the stereo field; detune ensemble. const cpan = Math.max(-0.85, Math.min(0.85, pan + (iv ? (iv - 4) * 0.04 : 0))); const ctone = recipe?.detune ? cf * (1 + recipe.detune * 0.01) : cf; const v = sound.synth({ type: useWave, tone: ctone, duration: Infinity, volume: vol * recipeVol, attack: atk, decay: currentDecay(), pan: cpan, // Route to the bespoke native GM C voice when this program has one // (0..7 piano family today); js_synth falls back to `type` for the // not-yet-implemented programs. Each C voice applies bounded // per-trigger stochasticism so the same note never renders an // identical waveform. gmProgram: gmProgram !== null ? gmProgram : undefined, }); if (v) voices.push(v); // Relay each chord tone over MIDI. const mn = Math.max(0, Math.min(127, noteToMidiNumber(letter, noteOctave) + iv)); system?.usbMidi?.noteOn?.(mn, velocityToMidi(velocity), 0); sendUdpMidiEvent(system, "note_on", mn, velocityToMidi(velocity), 0); } // Register with the chord-aware shape. We reuse compositeVoices so // stopSoundKey's existing multi-voice kill path handles release. const rootMidi = noteToMidiNumber(letter, noteOctave); sounds[key] = { synth: voices[0], compositeVoices: voices.length > 1 ? voices : null, voices, note: letter, octave: noteOctave, baseFreq: freq, gridOffset: offset, baseVol: baseVol * recipeVol, midiNote: rootMidi, midiChannel: 0, chordIvls: ivls.length > 1 ? ivls : null, velocity, // Shift-held arms the decaying linger tail; the chord fades as a // unit on release via stopSoundKey's multi-voice fade path. lingerOnRelease, lingerCat: recipe ? recipe.linger : "sustain", }; // Only Enter-hold permanently latches; shift now fades (above), so it // must NOT enter heldKeys. if (enterHeld) heldKeys.add(key); pushUsbMidiRecent(">", letter, noteOctave); trail[key] = { note: letter, octave: noteOctave, brightness: velocity }; return; } if (wave === "sample" && (sampleLoaded || sampleBank[key])) { // Only reload sample data if switching to a different sample const targetSample = sampleBank[key] || globalSample; if (targetSample && targetSample !== lastLoadedSample) { sound.sample.loadData(targetSample.data, targetSample.rate); lastLoadedSample = targetSample; } const sampleBase = targetSample?.base || SAMPLE_BASE_FREQ; // Clamp the resample ratio to ±1 octave (0.5×–2×). The engine plays // at speed = tone/base, so a mis-detected base could otherwise scream // or rumble; clamping the musical interval keeps every key sane. const sampleTone = clampSampleTone(playFreq, sampleBase); const smp = sound.sample.play({ tone: sampleTone, base: sampleBase, volume: vol, pan, loop: true, }); if (smp) { rememberSound(key, { synth: smp, note: letter, octave: noteOctave, baseFreq: freq, isSample: true, sampleBase, gridOffset: offset, baseVol }, system, velocity); } else { const synth = sound.synth({ type: "sine", tone: playFreq, duration: Infinity, volume: vol, attack: quickMode ? 0.002 : currentAttack(), decay: currentDecay(), pan, }); rememberSound(key, { synth, note: letter, octave: noteOctave, baseFreq: freq, gridOffset: offset, baseVol }, system, velocity); } } else { const synth = sound.synth({ type: wave, tone: playFreq, duration: Infinity, volume: vol, attack: quickMode ? 0.002 : currentAttack(), decay: currentDecay(), pan: pan, }); rememberSound(key, { synth, note: letter, octave: noteOctave, baseFreq: freq, gridOffset: offset, baseVol }, system, velocity); } // Legacy no-op hook from the older event-trigger reverse loop path. recordPlayback({ kind: "note", letter, octave: noteOctave, freq, vel: velocity, pan, wave, pitch: Math.pow(2, effectivePitchShift()), }); trail[key] = { note: letter, octave: noteOctave, brightness: velocity }; } } if (e.is("keyboard:up")) { const key = e.key?.toLowerCase(); if (!key) return; // Enter release: exit hold-modifier mode. Latched notes stay latched. if (key === "enter") { if (enterHeld) { enterHeld = false; // Disengage cue: descending pair sound?.synth?.({ type: "sine", tone: 990, duration: 0.06, volume: 0.10, attack: 0.002, decay: 0.05 }); sound?.synth?.({ type: "sine", tone: 660, duration: 0.08, volume: 0.08, attack: 0.005, decay: 0.07 }); } return; } // Space release: stop the reverse playback queue. The duration of the // press ends up defining the next loop's length. if (key === "space") { spaceHeld = false; stopReversePlayback(sound); // KEEP capture paused on release. If we unpause here, the ring // starts writing real-time silence between this release and the // next note press — which the next reverse press would then play // back as audible silence at the head of the loop ("space adds // time"). Lazily unpaused at the first note-trigger site below // (line ~2854) so the ring only captures content the user // actually plays. Press → release → press again with no notes // in between now reverses the same buffer content as press 1. spacePressStartMs = 0; return; } // Home key release: stop global recording + save to global sample if (key === "home" && recording && recPointerId === null) { stopSampleRecording(sound, "home-lift"); // Save global sample data for bank restore const data = sound.sample.getData?.(); if (data && data.length > 0) { const buf = new Float32Array(data); const rate = sound.microphone?.sampleRate || 48000; const base = detectFundamental(buf, rate) || SAMPLE_BASE_FREQ; const waveform = sampleWaveform(buf); globalSample = { data: buf, len: data.length, rate, base, waveform }; lastLoadedSample = null; // force reload on next key press console.log(`[sample-bank] global sample saved (${data.length} samples, base ${base.toFixed(1)}Hz)`); } return; } // End key release: disarm per-key recording if (key === "end") { endArmed = false; console.log(`[sample-bank] disarmed`); return; } // Per-key recording stop: tone key released while recording to it if (perKeyRecording && key === perKeyRecording) { const len = sound?.microphone?.cut?.() || 0; if (len > 0) { const data = sound.sample.getData?.(); if (data && data.length > 0) { const rate = sound.microphone?.sampleRate || 48000; // If this key was recorded while its grid side had percussion on, // save into the drum bank instead of the melodic sample bank. const recNoteName = KEY_TO_NOTE[key]; const [recLetter, recOffset] = recNoteName ? parseNote(recNoteName) : ["", 0]; const recDrum = recLetter ? percussionDrumFor(recLetter, recOffset) : null; if (recDrum) { percussionSampleBank[recDrum] = { data: new Float32Array(data), len: data.length, rate }; console.log(`[perc-bank] saved ${data.length} samples to drum '${recDrum}'`); } else { const buf = new Float32Array(data); // Anchor a per-key sample to the pitch of the key it was recorded // on: pressing that key plays it at 1.0× (its natural pitch), // neighbours pitch relative to it — deterministic, no detection // guesswork. The ±1-octave clamp at play bounds any octave shift. const recOctave = octave + recOffset; const base = noteToFreq(recLetter, recOctave) || SAMPLE_BASE_FREQ; const waveform = sampleWaveform(buf); sampleBank[key] = { data: buf, len: data.length, rate, base, waveform }; console.log(`[sample-bank] saved ${data.length} samples to key '${key}' (anchor ${base.toFixed(1)}Hz)`); } sampleLoaded = true; // Confirmation beep sound.synth({ type: "sine", tone: 660, duration: 0.05, volume: 0.15, attack: 0.002, decay: 0.04 }); } } perKeyRecording = null; return; } if (activeDrumKeys[key]) { releasePercussionHold(sound, activeDrumKeys[key]); delete activeDrumKeys[key]; return; } if (sounds[key]) { // Don't stop held/latched notes on key release if (heldKeys.has(key)) return; stopSoundKey(key, sound, system, quickMode ? 0.02 : 0.08); } } // Touch interactions if (e.is("touch")) { const y = e.pointer?.y ?? e.y ?? 0; const x = e.pointer?.x ?? e.x ?? 0; const w = globalThis.__screenW || 320; const h = globalThis.__screenH || 200; const pid = e.pointer?.id ?? 0; hoverX = x; hoverY = y; // "notepat.com" label: no longer tappable (use triple-escape to exit) // OS panel touch handling if (activeScreen === "os" && y > 14) { if (osState === "available") { // Cycle target button const cb = globalThis.__osCycleBtn; if (cb && y >= cb.y && y <= cb.y + cb.h && x >= cb.x && x <= cb.x + cb.w) { const targets = system?.flashTargets || []; flashTargetIdx = (flashTargetIdx + 1) % Math.max(1, targets.length); sound?.synth({ type: "sine", tone: 440, duration: 0.04, volume: 0.12, attack: 0.002, decay: 0.03 }); return; } // "update now" button const ub = globalThis.__osUpdateBtn; if (ub && y >= ub.y && y <= ub.y + ub.h && x >= ub.x && x <= ub.x + ub.w) { const targets = system?.flashTargets || []; const tgt = targets[flashTargetIdx]; const device = tgt?.device || undefined; console.log(`[os] manual download: ${OS_VMLINUZ_URL} -> /tmp/vmlinuz.new target=${device || "auto"}`); globalThis.__osFlashDevice = device; osState = "downloading"; osProgress = 0; system?.fetchBinary?.( OS_VMLINUZ_URL, "/tmp/vmlinuz.new", OS_VMLINUZ_BYTES ); } } return; // don't fall through to note grid } // Volume bar drag const vb = globalThis.__volBar; if (y < 16 && vb && x >= vb.x && x <= vb.x + vb.w) { volDragging = true; const pct = Math.max(0, Math.min(1, (x - vb.barX) / vb.barW)); const target = Math.round(pct * 20); // 0-20 steps const cur = Math.round((sound?.speaker?.systemVolume ?? 50) / 5); const diff = target - cur; if (diff !== 0) system?.volumeAdjust?.(diff > 0 ? 1 : -1); return; } // Brightness bar drag const bb = globalThis.__brtBar; if (y < 16 && bb && x >= bb.x && x <= bb.x + bb.w) { brtDragging = true; const pct = Math.max(0, Math.min(1, (x - bb.barX) / bb.barW)); const target = Math.round(pct * 20); const cur = Math.round((system?.brightness ?? 50) / 5); const diff = target - cur; if (diff !== 0) system?.brightnessAdjust?.(diff > 0 ? 1 : -1); return; } // WiFi fullscreen network list clicks (merged list) // First tap: select network. Second tap on same: connect. if (activeScreen === "wifi" && !wifiPasswordMode) { const merged = globalThis.__wifiMergedList || []; const rowH = 16; const listY = 44; const clickedRow = Math.floor((y - listY) / rowH); if (clickedRow >= 0 && clickedRow < merged.length) { const entry = merged[clickedRow]; if (entry.type === "separator") return; // ignore separator clicks // First tap — just select if (wifiSelectedIdx !== clickedRow) { wifiSelectedIdx = clickedRow; return; } // Second tap on same row — connect if (entry.type === "saved") { wifi?.connect(entry.ssid, entry.pass); activeScreen = "notepat"; } else if (entry.type === "scan") { const nets = wifi?.networks || []; const net = nets[entry.idx]; const savedCred = savedCreds.find((c) => c.ssid === net.ssid) || (net.ssid === AC_SSID ? { pass: AC_PASS } : null) || null; if (savedCred) { wifi?.connect(net.ssid, savedCred.pass); activeScreen = "notepat"; } else if (net.encrypted) { wifiPassword = ""; wifiPasswordMode = true; } else { wifi?.connect(net.ssid, ""); if (!savedCreds.find((c) => c.ssid === net.ssid)) savedCreds.push({ ssid: net.ssid, pass: "" }); activeScreen = "notepat"; } } } return; } // FX rows: sliders + per-effect X/Y trackpad assignment toggles { const fxRows = globalThis.__fxRows || {}; for (const rowId of ["fx", "echo", "pitch", "crush", "volume", "drive", "wobble"]) { const row = fxRows[rowId]; if (!row) continue; if (row.xBox && pointInRect(x, y, row.xBox)) { toggleTrackpadBinding(rowId, "x"); return; } if (row.yBox && pointInRect(x, y, row.yBox)) { toggleTrackpadBinding(rowId, "y"); return; } if (row.resetBox && pointInRect(x, y, row.resetBox)) { // Reset this effect to its zero/default value. Most effects // zero-out to 0; the fx dry/wet bar zeroes to 1.0 (full wet, // the "unity" passthrough state); volume zeroes to 0.5 (its // unity tick); pitch zeroes to 0 (no shift). if (rowId === "fx") { fxMix = 1.0; sound?.fx?.setMix?.(fxMix); } else if (rowId === "echo") setEchoMixValue(0, sound, true); else if (rowId === "pitch") setPitchShiftValue(0, true); else if (rowId === "crush") setBitcrushMixValue(0, sound, true); else if (rowId === "volume") setMasterVolMixValue(0.5, sound, true); else if (rowId === "drive") setDriveMixValue(0, sound, true); else if (rowId === "wobble") setWobbleMixValue(0, sound, true); // Tick feedback blip so the reset gesture feels confirmed. sound?.synth?.({ type: "sine", tone: 880, duration: 0.03, volume: 0.12, attack: 0.002, decay: 0.025 }); return; } if (pointInRect(x, y, { x: row.sliderX, y: row.y, w: row.sliderW, h: row.h })) { if (rowId === "fx") fxDragging = true; else if (rowId === "echo") echoDragging = true; else if (rowId === "pitch") pitchDragging = true; else if (rowId === "crush") bitcrushDragging = true; else if (rowId === "volume") masterVolDragging = true; else if (rowId === "drive") driveDragging = true; else if (rowId === "wobble") wobbleDragging = true; setEffectRowFromPointer(rowId, x, row, sound, true); return; } } } // Per-side master volume sliders (vertical bars flanking the grids) { const ls = globalThis.__leftMasterSlider; const rs = globalThis.__rightMasterSlider; if (ls && x >= ls.x && x < ls.x + ls.w && y >= ls.y && y < ls.y + ls.h) { leftVolDragging = true; leftMasterVol = Math.max(0, Math.min(1, 1 - (y - ls.y) / ls.h)); return; } if (rs && x >= rs.x && x < rs.x + rs.w && y >= rs.y && y < rs.y + rs.h) { rightVolDragging = true; rightMasterVol = Math.max(0, Math.min(1, 1 - (y - rs.y) / rs.h)); return; } } // DJ deck strip: play/pause icon + scratch pad const ds = globalThis.__djStrip; if (ds && y >= ds.y && y < ds.y + ds.h) { if (x < ds.iconW) { // Tap play/pause icon djTogglePlay(sound); } else { // Begin scratch drag const dk = sound?.deck; const d = dk?.decks?.[0]; if (d?.loaded) { djDragging = true; djDragWasPlaying = d.playing; djDragLastX = x; djScratchSpeed = 0; if (!d.playing) dk.play(0); } } return; } // Wave type / OCT / REC row const wb = globalThis.__waveButtons; if (wb && y >= wb.y && y < wb.y + wb.h) { if (wb.recX != null && x >= wb.recX) { // REC button — always visible. Press-and-hold to record into the // global sample slot. Auto-switch to "sample" wave so playback // hears the capture, no matter which wave was active when pressed. if (wave !== "sample") setWave("sample", sound); const ok = !!sound?.microphone?.rec?.(); recording = ok; recPointerId = ok ? pid : null; if (ok) recStartTime = Date.now(); const mic = sound?.microphone || {}; console.log(`[mic] rec-touch: ok=${ok} connected=${!!mic.connected} device=${mic.device || "none"} err=${mic.lastError || ""}`); if (!ok) sound?.synth?.({ type: "sine", tone: 220, duration: 0.08, volume: 0.16, attack: 0.002, decay: 0.06 }); } else if (x >= wb.octX) { // Octave button — tap to step (always visible). octave = (octave % 6) + 1; } else { const idx = Math.min(wavetypes.length - 1, Math.floor(x / wb.btnW)); setWave(wavetypes[idx], sound); } return; } // Grid button tap — check if touch lands on a note button const gridInfo = globalThis.__gridInfo; if (gridInfo) { const hitNote = hitTestGrid(x, y, gridInfo); if (hitNote && !sounds[hitNote.key]) { const freq = noteToFreq(hitNote.letter, hitNote.octave); const semitones = (hitNote.octave - 4) * 12 + CHROMATIC.indexOf(hitNote.letter); const pan = Math.max(-0.8, Math.min(0.8, (semitones - 12) / 15)); const playFreq = freq * Math.pow(2, effectivePitchShift()); // Percussion pad tap: fire drum (or drum sample) and flash trail. const touchDrum = percussionDrumFor(hitNote.letter, hitNote.gridOffset); if (touchDrum) { // Subtle note-based pitch harmonization — see keyboard drum // path for rationale. Each semitone = 1/36 octave of drum tuning. const touchDrumPitch = Math.pow(2, effectivePitchShift() + semitones / 36); // QWERTY physical key position → pan (matches where finger lands). const touchDrumPan = drumPanFor(hitNote.letter, hitNote.gridOffset, hitNote.key); const touchBaseVol = 1.8; const touchVol = touchBaseVol * masterForSide(hitNote.gridOffset); const drumHold = triggerPercussionDown( sound, hitNote.letter, hitNote.octave, touchVol, touchDrumPan, touchDrumPitch, hitNote.key, touchDrum, hitNote.gridOffset, touchBaseVol ); trail[hitNote.key] = { note: hitNote.letter, octave: hitNote.octave, brightness: 1.0 }; touchNotes[pid] = { key: hitNote.key, drumHold }; return; } let synth; if (wave === "sample" && sampleLoaded) { const smp = sound.sample.play({ tone: playFreq, base: SAMPLE_BASE_FREQ, volume: 0.5, pan, loop: true, }); if (smp) { synth = smp; rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq, isSample: true }, system, 1); } } else if (wave === "piano") { // Modal-additive grand piano. Single native voice — the engine // runs ~10 stretched-harmonic partials + hammer noise burst // internally (see audio.c:generate_piano_sample). Attack is // intentionally near-instant: real piano hammer strike is ~1ms. const dcy = currentDecay(); synth = sound.synth({ type: "piano", tone: playFreq, duration: Infinity, volume: 0.55, attack: 0.001, decay: dcy, pan, }); rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq, }, system, 1); } else { // Shift modifiers — per-instrument alternate sound recipes. // The global outline around the screen is the mode indicator; // each wave kit layers a complementary voice (or swaps params) // so "shift" reads sonically as "brighter / thicker / alternate" // rather than just boosted velocity. // // sine + shift → +octave bell ring at 30% volume // triangle + shift → +perfect 5th (tone × 1.5) at 25% // sawtooth + shift → detuned super-saw (offset +7 Hz) at 40% // square + shift → noise "grit" layer at 2× pitch, low vol // harp + shift → short staccato pluck (tight stretch in C) // whistle + shift → breathy "blow" — whistle + noise @ 4× // // Alternates release together with the primary voice via // compositeVoices bookkeeping so key-up kills everything. const isShortPluck = wave === "harp" && shiftHeld; synth = sound.synth({ type: wave, tone: playFreq, duration: isShortPluck ? 0.4 : Infinity, volume: shiftHeld && !isShortPluck ? 0.42 : 0.5, attack: currentAttack(), decay: isShortPluck ? 0.1 : currentDecay(), pan, }); // Layer the per-wave Shift alternate if applicable. const alts = [synth]; if (shiftHeld && !isShortPluck) { const atk = currentAttack(), dcy = currentDecay(); if (wave === "sine") { alts.push(sound.synth({ type: "sine", tone: playFreq * 2, duration: Infinity, volume: 0.15, attack: atk * 1.5, decay: dcy, pan, })); } else if (wave === "triangle") { alts.push(sound.synth({ type: "triangle", tone: playFreq * 1.5, duration: Infinity, volume: 0.12, attack: atk, decay: dcy, pan, })); } else if (wave === "sawtooth") { alts.push(sound.synth({ type: "sawtooth", tone: playFreq + 7, duration: Infinity, volume: 0.20, attack: atk, decay: dcy, pan, })); } else if (wave === "square") { alts.push(sound.synth({ type: "noise", tone: playFreq * 2, duration: Infinity, volume: 0.08, attack: Math.max(0.01, atk), decay: dcy, pan, })); } else if (wave === "whistle") { alts.push(sound.synth({ type: "noise", tone: playFreq * 4, duration: Infinity, volume: 0.18, attack: Math.max(0.02, atk), decay: dcy, pan, })); } } if (alts.length > 1) { rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq, compositeVoices: alts, }, system, 1); } else { rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq }, system, 1); } } if (!sounds[hitNote.key]) { synth = sound.synth({ type: "sine", tone: playFreq, duration: Infinity, volume: 0.5, attack: currentAttack(), decay: currentDecay(), pan, }); rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq }, system, 1); } trail[hitNote.key] = { note: hitNote.letter, octave: hitNote.octave, brightness: 1.0 }; touchNotes[pid] = { key: hitNote.key }; return; } } } if (e.is("draw")) { const y = e.pointer?.y ?? e.y ?? 0; const x = e.pointer?.x ?? e.x ?? 0; const w = globalThis.__screenW || 320; const pid = e.pointer?.id ?? 0; hoverX = x; hoverY = y; // Per-side master volume slider drag if (leftVolDragging) { const ls = globalThis.__leftMasterSlider; if (ls) { leftMasterVol = Math.max(0, Math.min(1, 1 - (y - ls.y) / ls.h)); } } if (rightVolDragging) { const rs = globalThis.__rightMasterSlider; if (rs) { rightMasterVol = Math.max(0, Math.min(1, 1 - (y - rs.y) / rs.h)); } } // Deck scratch: horizontal drag velocity → deck speed. // Positive dx forwards, negative dx backwards. Clamped to a reasonable range. if (djDragging) { const dk = sound?.deck; if (dk?.decks?.[0]?.loaded) { const dx = x - djDragLastX; // 1 px ≈ 0.08 speed units; clamp to [-3, 3] djScratchSpeed = Math.max(-3, Math.min(3, dx * 0.08)); dk.setSpeed(0, djScratchSpeed); } djDragLastX = x; } const fxRows = globalThis.__fxRows || {}; if (fxDragging) setEffectRowFromPointer("fx", x, fxRows.fx, sound, true); if (echoDragging) setEffectRowFromPointer("echo", x, fxRows.echo, sound, true); if (pitchDragging) setEffectRowFromPointer("pitch", x, fxRows.pitch, sound, true); if (bitcrushDragging) setEffectRowFromPointer("crush", x, fxRows.crush, sound, true); if (masterVolDragging) setEffectRowFromPointer("volume", x, fxRows.volume, sound, true); if (driveDragging) setEffectRowFromPointer("drive", x, fxRows.drive, sound, true); if (wobbleDragging) setEffectRowFromPointer("wobble", x, fxRows.wobble, sound, true); if (volDragging) { const vb = globalThis.__volBar; if (vb) { const pct = Math.max(0, Math.min(1, (x - vb.barX) / vb.barW)); const target = Math.round(pct * 20); const cur = Math.round((sound?.speaker?.systemVolume ?? 50) / 5); const diff = target - cur; if (diff !== 0) system?.volumeAdjust?.(diff > 0 ? 1 : -1); } } if (brtDragging) { const bb = globalThis.__brtBar; if (bb) { const pct = Math.max(0, Math.min(1, (x - bb.barX) / bb.barW)); const target = Math.round(pct * 20); const cur = Math.round((system?.brightness ?? 50) / 5); const diff = target - cur; if (diff !== 0) system?.brightnessAdjust?.(diff > 0 ? 1 : -1); } } // Grid rollover: dragging across note buttons triggers the new one const gridInfo = globalThis.__gridInfo; if (gridInfo && touchNotes[pid] !== undefined) { const hitNote = hitTestGrid(x, y, gridInfo); if (hitNote && hitNote.key && hitNote.key !== touchNotes[pid]?.key) { // Release current note releaseTouchNote(pid, sound, system, 0.02); // Trigger new note if (!sounds[hitNote.key]) { const freq = noteToFreq(hitNote.letter, hitNote.octave); const semitones = (hitNote.octave - 4) * 12 + CHROMATIC.indexOf(hitNote.letter); const pan = Math.max(-0.8, Math.min(0.8, (semitones - 12) / 15)); const playFreq = freq * Math.pow(2, effectivePitchShift()); // Drag-to-drum: drum pads fire as one-shots on rollover too. const rollDrum = percussionDrumFor(hitNote.letter, hitNote.gridOffset); if (rollDrum) { // Subtle note-based pitch harmonization (see keyboard path). const rollDrumPitch = Math.pow(2, effectivePitchShift() + semitones / 36); // QWERTY physical key position → pan (matches where finger lands). const rollDrumPan = drumPanFor(hitNote.letter, hitNote.gridOffset, hitNote.key); const rollBaseVol = 1.8; const rollVol = rollBaseVol * masterForSide(hitNote.gridOffset); const drumHold = triggerPercussionDown( sound, hitNote.letter, hitNote.octave, rollVol, rollDrumPan, rollDrumPitch, hitNote.key, rollDrum, hitNote.gridOffset, rollBaseVol ); trail[hitNote.key] = { note: hitNote.letter, octave: hitNote.octave, brightness: 1.0 }; touchNotes[pid] = { key: hitNote.key, drumHold }; return; } let synth; if (wave === "sample" && sampleLoaded) { const smp = sound.sample.play({ tone: playFreq, base: SAMPLE_BASE_FREQ, volume: 0.5, pan, loop: true, }); if (smp) { synth = smp; rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq, isSample: true }, system, 1); } } else { synth = sound.synth({ type: wave, tone: playFreq, duration: Infinity, volume: 0.5, attack: currentAttack(), decay: currentDecay(), pan, }); rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq }, system, 1); } if (!sounds[hitNote.key]) { synth = sound.synth({ type: "sine", tone: playFreq, duration: Infinity, volume: 0.5, attack: currentAttack(), decay: currentDecay(), pan, }); rememberSound(hitNote.key, { synth, note: hitNote.letter, octave: hitNote.octave, baseFreq: freq }, system, 1); } trail[hitNote.key] = { note: hitNote.letter, octave: hitNote.octave, brightness: 1.0 }; touchNotes[pid] = { key: hitNote.key }; } } } } if (e.is("lift")) { const pid = e.pointer?.id ?? 0; // Stop REC only when the REC-holding pointer is released. if (recording && (recPointerId === null || recPointerId === pid)) { stopSampleRecording(sound, "touch-lift"); } // Deck scratch release — restore normal speed and resume or pause. if (djDragging) { const dk = sound?.deck; if (dk?.decks?.[0]?.loaded) { dk.setSpeed(0, 1); if (djDragWasPlaying) dk.play(0); else dk.pause(0); } djDragging = false; djScratchSpeed = 0; } if (leftVolDragging) leftVolDragging = false; if (rightVolDragging) rightVolDragging = false; if (fxDragging) fxDragging = false; if (echoDragging) echoDragging = false; if (pitchDragging) pitchDragging = false; if (bitcrushDragging) bitcrushDragging = false; if (masterVolDragging) masterVolDragging = false; if (driveDragging) driveDragging = false; if (wobbleDragging) wobbleDragging = false; if (volDragging) volDragging = false; if (brtDragging) brtDragging = false; // Release touch-triggered note if (touchNotes[pid]) { releaseTouchNote(pid, sound, system, 0.08); } } } // === BANDMATE CHARACTERS === // Each wave-type has a procedural bandmate drawn in the empty middle space // between the left/right note grids. Sprites are 18×22 char grids ('.' = // transparent), block size 2, so ~36×44 px on screen. Two frames each for // idle sway / playing dance — swap rate keys off note activity. Special // characters in each grid are interpreted by drawBandmate(): // E = eye-white (a 1px pupil is overlaid on top, looking at the cursor) // M = mouth (neutral resting) // N = mouth (excited/playing) — drawn over M when activeCount > 0 // = = animated string / instrument detail (shimmer on fresh pluck) // O = origin marker for emitted music notes (transparent — sprite reads // through, but drawBandmate caches the X/Y to spawn ♪ from) // palette key '+' = bright shimmer color for '=' on fresh pluck // palette key 'P' = pupil color const CHARACTER_SPRITES = { // Existing harpist — now with addressable eyes (E) + dual mouth (M/N) // and a string-pluck origin (O) for note emission. harp: { palette: { H: [180, 90, 60], h: [140, 60, 30], S: [240, 200, 170], M: [200, 60, 70], N: [255, 100, 120], L: [200, 60, 70], D: [180, 120, 220], d: [120, 70, 160], "=": [255, 230, 140], "+": [255, 255, 200], F: [220, 180, 80], P: [25, 15, 30], }, paletteLight: { H: [140, 60, 30], h: [110, 50, 20], S: [240, 200, 170], M: [200, 60, 70], N: [230, 80, 100], L: [200, 60, 70], D: [150, 80, 200], d: [100, 50, 150], "=": [200, 160, 40], "+": [240, 200, 80], F: [180, 120, 40], P: [20, 10, 25], }, spriteA: [ "....HHHHHH........", "...HHHHHHHH.......", "..HHEEHHEEHH......", "..HHEEHHEEHH......", "..HHHHMMHHHH......", "..HHHLLLLHHH......", "...HHHHHHHH.......", "....SSSSSS........", "...DDDDDDDD..FF...", "..DDDDDDDDDD.FF...", "..DDDDDDDDDDFF....", "..DDDDdDDDDD=F....", "..DDDDddDDDDO=F...", "..DDDDdDdDDD=F....", "...DDDDDDDD.F=F...", "....dddddd..F=F...", "....dd..dd..F=F...", "....dd..dd..FFF...", "...ddd..ddd.......", "..ddd....ddd......", "..ddd....ddd......", "..ddd....ddd......", ], spriteB: [ "....HHHHHH........", "...HHHHHHHH.......", "..HHEEHHEEHH......", "..HHEEHHEEHH......", "..HHHHNNHHHH......", "..HHHLLLLHHH......", "...HHHHHHHH.......", "....SSSSSS........", "...DDDDDDDD.FF....", "..DDDDDDDDDDFF....", "..DDDDDDDDDD=F....", "..DDDDdDDDDD==F...", "..DDDDddDDDD=F....", "..DDDDdDdDDDFO=F..", "...DDDDDDDDFF=F...", "....dddddd.F=F....", "....dd..dd.FFF....", "....dd..dd........", "...ddd..ddd.......", "..ddd....ddd......", "..ddd....ddd......", "..ddd....ddd......", ], }, // Sine Guy — round mellow dude with a sine-wave halo above his head. // Signature gesture: nodding head, slow swayed hair, calm expression. sine: { palette: { H: [40, 80, 200], h: [30, 60, 150], S: [240, 210, 180], C: [60, 120, 255], M: [180, 80, 80], N: [220, 100, 110], L: [180, 80, 80], W: [120, 180, 255], "+": [200, 230, 255], B: [40, 60, 140], b: [30, 45, 100], P: [10, 20, 50], }, paletteLight: { H: [60, 100, 220], h: [40, 70, 170], S: [240, 210, 180], C: [40, 100, 240], M: [180, 60, 70], N: [210, 80, 90], L: [180, 60, 70], W: [80, 140, 230], "+": [180, 220, 255], B: [40, 70, 160], b: [30, 50, 110], P: [10, 15, 40], }, // The "WW" line above the head is a tiny sine-wave halo. spriteA: [ ".......WW.........", "....WWW..WWW......", "..WW........WW....", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHSSEESSEEHH...", "..HHHSSEESSEEHH...", "..HHSSSSSSSSSHH...", "..HHSSSSMMSSSHH...", "..hhSSSLLLLSShh...", "...hhhSSSSSShh....", "....SSSSSSSS......", "...BBBBBBBBBB.....", "..BBBBBBBBBBBB....", "..BBBBBBBBBBBB....", "..bbBBBBBBBBbb....", "..bbBBBBBBBBbb....", "...bbbBBBBbbb.....", "....SS....SS......", "....SS....SS......", "....SS....SS......", "...SSS....SSS.....", ], spriteB: [ ".........WW.......", "......WWW..WWW....", "....WW........WW..", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHSSEESSEEHH...", "..HHHSSEESSEEHH...", "..HHSSSSSSSSSHH...", "..HHSSSSNNSSSHH...", "..hhSSSLLLLSShh...", "...hhhSSSSSShh....", "....SSSSOSSS......", "...BBBBBBBBBB.....", "..BBBBBBBBBBBB....", "..BBBBBBBBBBBB....", "..bbBBBBBBBBbb....", "..bbBBBBBBBBbb....", "...bbbBBBBbbb.....", "...SSS....SSS.....", "...SS......SS.....", "..SS........SS....", ".SSS........SSS...", ], }, // Triangle Girl — pointy hat, triangular dress, sharp angular sway. triangle: { palette: { H: [20, 120, 60], h: [15, 90, 45], S: [240, 220, 200], T: [60, 200, 80], t: [30, 140, 50], M: [200, 90, 110], N: [240, 120, 140], L: [200, 90, 110], D: [80, 220, 120], d: [40, 160, 80], "+": [200, 255, 220], P: [10, 30, 15], }, paletteLight: { H: [20, 100, 50], h: [15, 70, 35], S: [240, 220, 200], T: [40, 180, 70], t: [25, 130, 50], M: [200, 80, 100], N: [220, 100, 120], L: [200, 80, 100], D: [60, 200, 100], d: [30, 150, 70], "+": [180, 240, 200], P: [10, 25, 15], }, spriteA: [ "........T.........", ".......TTT........", "......TTTTT.......", ".....TTTtTTT......", "....HHHHHHHH......", "...HHEEHHEEHH.....", "...HHEEHHEEHH.....", "...HHSSSSSSHH.....", "...HHSSMMSSHH.....", "....SSLLLLSS......", ".....SSSSSS.......", "......DDDD........", ".....DDDDDD.......", "....DDDDDDDD......", "...DDDDOdDDDD.....", "..DDDDDDDDDDDD....", ".DDDDDdDDdDDDDD...", "DDDDDDDDDDDDDDDD..", "....SS....SS......", "....SS....SS......", "...SSS....SSS.....", "..SSS......SSS....", ], spriteB: [ "........T.........", ".......TTT........", "......TTTTT.......", ".....TTTtTTT......", "....HHHHHHHH......", "...HHEEHHEEHH.....", "...HHEEHHEEHH.....", "...HHSSSSSSHH.....", "...HHSSNNSSHH.....", "....SSLLLLSS......", ".....SSSSSS.......", "......DDDD........", ".....DDDDDD.......", "....DDDOdDDD......", "...DDDDDDDDDD.....", "..DDDDDdDDDDDD....", ".DDDDDDdDdDDDDD...", "DDDDDDDDDDDDDDDD..", "....SSS..SSS......", "...SSS....SSS.....", "...SS......SS.....", "..SSS......SSS....", ], }, // Square Bot — boxy android, antenna, screen-face, segmented arms. square: { palette: { H: [120, 80, 180], h: [80, 50, 130], S: [200, 200, 220], M: [220, 80, 220], N: [255, 120, 255], L: [200, 60, 200], B: [160, 80, 220], b: [110, 50, 160], "=": [255, 220, 80], "+": [255, 255, 200], A: [60, 60, 80], P: [255, 80, 200], }, paletteLight: { H: [110, 70, 170], h: [70, 45, 120], S: [220, 220, 240], M: [200, 70, 200], N: [230, 100, 230], L: [180, 50, 180], B: [140, 70, 200], b: [90, 40, 140], "=": [220, 180, 40], "+": [240, 220, 100], A: [60, 60, 80], P: [200, 50, 160], }, spriteA: [ "........A.........", "........A.........", ".......AAA........", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHSSSSSSSSHH....", "..HHSEESSEESSHH...", "..HHSEESSEESSHH...", "..HHSSSSSSSSHH....", "..HHSSMMMMSSHH....", "..hhSSLLLLSShh....", "...hhSSSSSShh.....", "....BBBBBBBB......", "...BBBBOBBBB......", "..BBBBBBBBBBBB....", "..BBBB====BBBB....", "..BBBBBBBBBBBB....", "..bbbBBBBBBbbb....", "...bb......bb.....", "...BB......BB.....", "..BBB......BBB....", "..AAA......AAA....", ], spriteB: [ "........A.........", ".......AAA........", "........A.........", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHSSSSSSSSHH....", "..HHSEESSEESSHH...", "..HHSEESSEESSHH...", "..HHSSSSSSSSHH....", "..HHSSNNNNSSHH....", "..hhSSLLLLSShh....", "...hhSSSSSShh.....", "....BBBBBBBB......", "....BBBOBBBB......", "..BBBBBBBBBBBB....", "..BBBB====BBBB....", "..BBBBBBBBBBBB....", "..bbbBBBBBBbbb....", "...BB......BB.....", "...bb......bb.....", "..BBB......BBB....", "..AAA......AAA....", ], }, // Sinister Sawtooth — jagged teeth, spiky hair, hollow malevolent eyes. sawtooth: { palette: { H: [80, 30, 30], h: [50, 15, 15], S: [180, 160, 150], M: [40, 0, 0], N: [255, 0, 0], L: [255, 220, 220], D: [120, 30, 40], d: [80, 15, 25], Z: [255, 100, 0], z: [200, 60, 0], "+": [255, 220, 100], P: [255, 200, 0], }, paletteLight: { H: [70, 25, 25], h: [40, 12, 12], S: [200, 170, 160], M: [40, 0, 0], N: [200, 0, 0], L: [180, 60, 70], D: [110, 30, 40], d: [70, 15, 25], Z: [220, 80, 0], z: [180, 50, 0], "+": [255, 200, 80], P: [200, 50, 0], }, spriteA: [ "..H.H.H.H.H.H.H...", "..HHHHHHHHHHHHH...", ".HHHHHHHHHHHHHHH..", ".HHHEEHHHHHEEHHH..", ".HHHEEHHHHHEEHHH..", ".HHSSSSSSSSSSSHH..", ".HHSSSSSSSSSSSHH..", ".HHSSMMMMMMMSSHH..", ".hhSSLLLLLLLLShh..", "..hhSSSSSSSSShh...", "...DDDDDDDDDD.....", "..DDDDDDDDDDDD....", ".DDDDOZzZzZDDDD...", ".DDDDZzZzZzDDDD...", ".DDDDzZzZzZDDDD...", "..ddDDDDDDDDdd....", "...ddDDDDDDdd.....", "....SS....SS......", "...SS......SS.....", "..SS........SS....", ".SSS........SSS...", ".SS..........SS...", ], spriteB: [ ".H.H.H.H.H.H.H.H..", ".HHHHHHHHHHHHHHH..", "HHHHHHHHHHHHHHHHH.", "HHHHEEHHHHHEEHHHH.", "HHHHEEHHHHHEEHHHH.", ".HHSSSSSSSSSSSHH..", ".HHSSSSSSSSSSSHH..", ".HHSSNNNNNNNSSHH..", ".hhSSLLLLLLLLShh..", "..hhSSSSSSSSShh...", "...DDDDDDDDDD.....", "..DDDDDDDDDDDD....", ".DDDDZzZzZzDDDD...", ".DDDDOZzZzZzDDDD..", ".DDDDzZzZzZDDDD...", "..ddDDDDDDDDdd....", "...ddDDDDDDdd.....", "...SSS....SSS.....", "..SSS......SSS....", ".SSS........SSS...", "SSS..........SSS..", "SS............SS..", ], }, // Whistling Wanderer — hooded traveler, puckered/whistling lips, pack // on back. Lone-traveller silhouette; the 'O' marker is at the lips so // emitted notes drift from his pucker. whistle: { palette: { H: [80, 70, 60], h: [50, 45, 40], S: [240, 210, 180], M: [120, 60, 50], N: [200, 100, 80], L: [200, 100, 80], C: [180, 150, 110], c: [120, 100, 70], G: [60, 90, 50], g: [40, 65, 35], "+": [255, 230, 180], P: [30, 25, 20], }, paletteLight: { H: [90, 80, 70], h: [60, 55, 50], S: [240, 210, 180], M: [110, 50, 40], N: [180, 80, 60], L: [180, 80, 60], C: [200, 170, 130], c: [140, 110, 80], G: [70, 100, 60], g: [50, 75, 45], "+": [240, 220, 170], P: [25, 20, 15], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "..HHSSSMMOSSHH....", "..HHSSSLLSSSHH....", "..hhSSSSSSSShh....", "...hhhSSSSShhh....", "....CCCCCCCC......", "...CCCCCCCCCC..gg.", "..CCCCCCCCCCCCGG..", "..CCCCCCCCCCCCGG..", "..ccCCCCCCCCccGg..", "..ccCCCCCCCCcc....", "...cccCCCCccc.....", "....cc....cc......", "....cc....cc......", "...ccc....ccc.....", "..GGG......GGG....", "..ggg......ggg....", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "..HHSSSNNOSSHH....", "..HHSSSLLSSSHH....", "..hhSSSSSSSShh....", "...hhhSSSSShhh....", "....CCCCCCCC......", "...CCCCCCCCCC.gg..", "..CCCCCCCCCCCGG...", "..CCCCCCCCCCCGG...", "..ccCCCCCCCCccG...", "..ccCCCCCCCCcc....", "...cccCCCCccc.....", "....cc....cc......", "...ccc....cc......", "...cc......ccc....", "..GGG......GGG....", "..ggg......ggg....", ], }, // 🎩 Piano Man — formal pianist with bowler hat, mustache, red // bowtie, sitting at a piano keyboard. The '=' row is the keys; on // play they flip to '+' for a highlight flash. M ↔ N swaps the // mustache to a slightly excited shape on note hits. piano: { palette: { H: [40, 40, 50], h: [25, 25, 35], F: [220, 180, 80], S: [240, 200, 170], M: [80, 50, 60], N: [120, 70, 80], L: [150, 90, 100], B: [60, 50, 70], b: [40, 35, 50], C: [180, 60, 60], "=": [240, 235, 220], "+": [255, 250, 200], c: [30, 25, 40], P: [10, 10, 20], }, paletteLight: { H: [60, 60, 70], h: [40, 40, 55], F: [200, 160, 60], S: [240, 200, 170], M: [70, 40, 50], N: [110, 60, 70], L: [140, 80, 90], B: [80, 70, 90], b: [55, 45, 65], C: [180, 60, 60], "=": [240, 235, 220], "+": [240, 200, 100], c: [30, 25, 40], P: [10, 10, 20], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHFFFFFFFFHH....", "..SSEESSSSEESS....", "..SSEESSSSEESS....", "..SSSSSSSSSSSS....", "..SSSMMMMMMSSS....", "..SSSSLLLLSSSS....", "...SSSOSSSSS......", "....BBBBBBBB......", "...BBBCCCCBBB.....", "..BBBBBBBBBBBB....", "..BBBBBBBBBBBB....", "..BB========BB....", "..BB========BB....", "..BB========BB....", "..BBBBBBBBBBBB....", "...bbb....bbb.....", "...bbb....bbb.....", "...bbb....bbb.....", "..ccc......ccc....", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHFFFFFFFFHH....", "..SSEESSSSEESS....", "..SSEESSSSEESS....", "..SSSSSSSSSSSS....", "..SSSNNNNNNSSS....", "..SSSSLLLLSSSS....", "...SSSOSSSSS......", "....BBBBBBBB......", "...BBBCCCCBBB.....", "..BBBBBBBBBBBB....", "..BBBBBBBBBBBB....", "..BB++++++++BB....", "..BB========BB....", "..BB++++++++BB....", "..BBBBBBBBBBBB....", "...bbb....bbb.....", "...bbb....bbb.....", "...bbb....bbb.....", "..ccc......ccc....", ], }, // 👩 Sample Sally — modern sampler artist with wavy brown hair, pink // skin, teal shirt, and a row of sampler pads. The pads are the '=' // row; pressing notes flips them to '+' for a per-pad pulse glow. sample: { palette: { H: [120, 70, 40], h: [80, 45, 25], S: [245, 215, 195], M: [170, 80, 90], N: [220, 100, 120], L: [160, 60, 80], D: [80, 130, 160], d: [50, 90, 120], "=": [200, 50, 70], "+": [255, 100, 130], g: [50, 35, 25], P: [25, 15, 30], }, paletteLight: { H: [110, 60, 30], h: [70, 40, 20], S: [245, 215, 195], M: [160, 70, 80], N: [200, 90, 110], L: [150, 50, 70], D: [60, 110, 150], d: [40, 80, 110], "=": [200, 50, 70], "+": [240, 80, 110], g: [40, 30, 20], P: [20, 10, 25], }, spriteA: [ ".....HHHHHH.......", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..hhEESSSSEEhh....", "..hhEESSSSEEhh....", "..HHSSSSSSSSHH....", "...SSSMMMMSSS.....", "....SSLLLLSSO.....", ".....SSSSSS.......", "....DDDDDDDD......", "...DDDDDDDDDD.....", "..DDDDDDDDDDDD....", "..DD========DD....", "..DD========DD....", "..DD========DD....", "..DDDDDDDDDDDD....", "..ddddddddddd.....", "....dddd.dddd.....", "....ddd...ddd.....", "...ddd.....ddd....", "..ggg.......ggg...", ], spriteB: [ ".....HHHHHH.......", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..hhEESSSSEEhh....", "..hhEESSSSEEhh....", "..HHSSSSSSSSHH....", "...SSSNNNNSSS.....", "....SSLLLLSSO.....", ".....SSSSSS.......", "....DDDDDDDD......", "...DDDDDDDDDD.....", "..DDDDDDDDDDDD....", "..DD++++====DD....", "..DD====++++DD....", "..DD++++====DD....", "..DDDDDDDDDDDD....", "..ddddddddddd.....", "....dddd.dddd.....", "....ddd...ddd.....", "...ddd.....ddd....", "..ggg.......ggg...", ], }, }; // === GENERAL MIDI FAMILY BANDMATES === // 128 unique characters is too many to author well, so each of the 16 GM // families (8 programs apiece — Piano 0-7, Chromatic Perc 8-15, Organ // 16-23, … Sound FX 120-127) gets ONE bandmate in the same pixel-art style // as CHARACTER_SPRITES above: a palette + paletteLight map of single-char → // [r,g,b], plus spriteA/spriteB animation frames ~18 wide × 22 tall. Every // one carries the shared "bandmate" personality — eyes (E) that track the // cursor, a dual mouth (M/N), and at least one note-emission origin (O) — // over a family-specific instrument motif (keyboard / bell / organ pipes / // guitar body / bass / violin+bow / choir / horn bell / reed / pan-flute / // lead bolt / pad cloud / sparkle / sitar / drum / radio). A GM voice draws // its family's character and shows the specific program NAME beneath it, so // all 128 stay recognizable from 16 sprites. Keyed by family index 0..15; // gmFamilyKey(program) resolves a program to its key. drawBandmate() and the // chooser both look up CHARACTER_SPRITES first, then GM_FAMILY_SPRITES. const GM_FAMILY_SPRITES = { // 0 — Piano (0-7): grand-piano keytop head, ivory + ebony key teeth. gm0: { palette: { H: [40, 40, 55], h: [25, 25, 38], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], K: [240, 235, 220], k: [30, 28, 40], F: [220, 180, 80], O: [0, 0, 0], P: [10, 10, 20], E: [255, 255, 255], }, paletteLight: { H: [60, 60, 75], h: [40, 40, 55], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], K: [250, 248, 240], k: [40, 35, 50], F: [200, 160, 60], O: [0, 0, 0], P: [10, 10, 20], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHFFFFFFFFHH....", "..HHKkKkKkKkHH....", "..SSEESSSSEESS....", "..SSEESSSSEESS....", "..SSSSSSSSSSSS....", "..SSSMMMMMMSSS....", "..SSSSLLLLSSSS....", "...SSSSOSSSS......", "....HHHHHHHH......", "...KKKKKKKKKK.....", "..KKkKKkKKkKKK....", "..KKkKKkKKkKKK....", "..KKKKKKKKKKKK....", "..KKkKKkKKkKKK....", "..KKKKKKKKKKKK....", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", ".hhh........hhh...", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHFFFFFFFFHH....", "..HHKkKkKkKkHH....", "..SSEESSSSEESS....", "..SSEESSSSEESS....", "..SSSSSSSSSSSS....", "..SSSNNNNNNSSS....", "..SSSSLLLLSSSS....", "...SSSSOSSSS......", "....HHHHHHHH......", "...KKKKKKKKKK.....", "..KKKKKKKKKKKK....", "..KKkKKkKKkKKK....", "..KKkKKkKKkKKK....", "..KKKKKKKKKKKK....", "..KKkKKkKKkKKK....", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", ".hhh........hhh...", ], }, // 1 — Chromatic Percussion (8-15): glockenspiel/bells, mallet in hand. gm1: { palette: { H: [200, 160, 60], h: [150, 110, 30], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], B: [120, 210, 230], b: [70, 150, 180], W: [255, 250, 200], O: [255, 250, 200], P: [10, 20, 30], E: [255, 255, 255], }, paletteLight: { H: [200, 160, 60], h: [150, 110, 30], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], B: [90, 180, 210], b: [50, 130, 160], W: [240, 230, 150], O: [240, 230, 150], P: [10, 20, 30], E: [255, 255, 255], }, spriteA: [ ".....HHHHHH.......", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSSMMMMSSS.....", "...SSSSLLSSSO.....", "....SSSSSSSS......", "...BBBBBBBBBB.....", "..BBbBBbBBbBBb....", "..BBBBBBBBBBBB....", "..bbbbbbbbbbbb....", "....W.....W.W.....", "....W....hh.......", "....W...hh........", "....W..hh.........", "...hh.............", "..hh..............", "..h...............", "..................", "..................", ], spriteB: [ ".....HHHHHH.......", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSSNNNNSSS.....", "...SSSSLLSSSO.....", "....SSSSSSSS......", "...BBBBBBBBBB.....", "..BBbBBbBBbBBb....", "..BBBBBBBBBBBB....", "..bbbbbbbbbbbb....", "..WW......W.W.....", "...hh.....W.......", "....hh....W.......", ".....hh...W.......", "......hh.hh.......", ".......hh.........", ".......h..........", "..................", "..................", ], }, // 2 — Organ (16-23): cathedral pipe crown over the head. gm2: { palette: { H: [150, 110, 70], h: [100, 70, 40], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [200, 200, 215], g: [120, 120, 140], W: [240, 220, 120], O: [240, 220, 120], P: [20, 15, 25], E: [255, 255, 255], }, paletteLight: { H: [150, 110, 70], h: [100, 70, 40], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [180, 180, 195], g: [110, 110, 130], W: [210, 180, 80], O: [210, 180, 80], P: [20, 15, 25], E: [255, 255, 255], }, spriteA: [ "..G.G.G.G.G.G.G...", "..G.G.G.G.G.G.G...", "..GgGgGgGgGgGgG...", "..gggggggggggg....", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSMMMMMMSS.....", "...SSSLLLLSSO.....", "....SSSSSSSS......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHHWWWWWWHHH....", "..HHHHHHHHHHHH....", "..HHHHHHHHHHHH....", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", "..................", "..................", ], spriteB: [ "..G.G.G.G.G.G.G...", "..G.G.G.G.G.G.G...", "..GgGgGgGgGgGgG...", "..gggggggggggg....", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSNNNNNNSS.....", "...SSSLLLLSSO.....", "....SSSSSSSS......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHHWWWWWWHHH....", "..HHHWWWWWWHHH....", "..HHHHHHHHHHHH....", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", "..................", "..................", ], }, // 3 — Guitar (24-31): hollow-body guitar with soundhole O. gm3: { palette: { H: [120, 70, 40], h: [80, 45, 25], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [200, 140, 70], g: [150, 95, 45], W: [240, 230, 200], O: [25, 15, 10], P: [20, 12, 8], E: [255, 255, 255], }, paletteLight: { H: [120, 70, 40], h: [80, 45, 25], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [200, 140, 70], g: [150, 95, 45], W: [220, 205, 170], O: [25, 15, 10], P: [20, 12, 8], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSMMMMMMSS.....", "...SSSLLLLSSS.....", "....SSSSSSSS......", ".....GGGGGG.......", "....GGGGGGGG......", "...GGGGGGGGGG.....", "..GGGGGOOGGGGG....", "..GGGGGOOGGGGG....", "..GGGGGGGGGGGG....", "...GGGGGGGGGG.....", "....GGGGGGGG......", ".....gWWWWg.......", ".....gWWWWg.......", ".....gWWWWg.......", ".....gWWWWg.......", ".....g....g.......", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSNNNNNNSS.....", "...SSSLLLLSSS.....", "....SSSSSSSS......", ".....GGGGGG.......", "....GGGGGGGG......", "...GGGGGGGGGG.....", "..GGGGGOOGGGGG....", "..GGGGGOOGGGGG....", "..GGGGGGGGGGGG....", "...GGGGGGGGGG.....", "....GGGGGGGG......", ".....WggggW.......", ".....WggggW.......", ".....WggggW.......", ".....WggggW.......", ".....g....g.......", "..................", ], }, // 4 — Bass (32-39): big-bodied low dude with a 4-string bass neck. gm4: { palette: { H: [60, 50, 90], h: [40, 33, 65], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], B: [90, 70, 140], b: [60, 45, 100], W: [220, 210, 180], O: [220, 210, 180], P: [15, 12, 30], E: [255, 255, 255], }, paletteLight: { H: [80, 70, 120], h: [55, 48, 85], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], B: [110, 90, 170], b: [75, 60, 120], W: [200, 190, 160], O: [200, 190, 160], P: [15, 12, 30], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "..HSSSMMMMSSSH....", "..HSSSSLLSSSSH....", "...SSSSSSSSO......", "...BBBBBBBBBB.....", "..BBBBBBBBBBBB....", "..BBBBBBBBBBBB....", "..BBBWWWWWWBBB....", "..bbBBBBBBBBbb....", "..bbBBBBBBBBbb....", "...bbbBBBBbbb.....", "....WWWWWWWW......", "....W.W.W.W.......", "....W.W.W.W.......", "...bbb....bbb.....", "..bbb......bbb....", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "..HSSSNNNNSSSH....", "..HSSSSLLSSSSH....", "...SSSSSSSSO......", "...BBBBBBBBBB.....", "..BBBBBBBBBBBB....", "..BBBWWWWWWBBB....", "..BBBBBBBBBBBB....", "..bbBBBBBBBBbb....", "..bbBBBBBBBBbb....", "...bbbBBBBbbb.....", "....WWWWWWWW......", "....W.W.W.W.......", "....W.W.W.W.......", "...bbb....bbb.....", "..bbb......bbb....", "..................", ], }, // 5 — Strings (40-47): violinist, instrument tucked at chin + bow O. gm5: { palette: { H: [150, 90, 50], h: [110, 65, 35], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], V: [180, 110, 50], v: [120, 70, 30], W: [230, 220, 190], O: [230, 220, 190], P: [20, 12, 8], E: [255, 255, 255], }, paletteLight: { H: [150, 90, 50], h: [110, 65, 35], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], V: [180, 110, 50], v: [120, 70, 30], W: [210, 200, 170], O: [210, 200, 170], P: [20, 12, 8], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSMMMMMMSS.....", "...SSSLLLLSSS.....", "....SSSSSSVV......", ".....SSSVVVV......", "....WVVVVVVVW.....", "...WVVVVVVVVVW....", "..WOVVVvvVVVVVW...", "...WVVVvvVVVVW....", "....WVVVVVVVW.....", ".....VVVVVVV......", "......vvvvv.......", ".....hhh.hhh......", "....hhh...hhh.....", "...hhh.....hhh....", "..hhh.......hhh...", "..................", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSNNNNNNSS.....", "...SSSLLLLSSS.....", "....SSSSSSVV......", ".....SSSVVVV......", "....WVVVVVVVW.....", "...WVVVVVVVVVW....", "..WVVVVvvVVVVOW...", "...WVVVvvVVVVW....", "....WVVVVVVVW.....", ".....VVVVVVV......", "......vvvvv.......", ".....hhh.hhh......", "....hhh...hhh.....", "...hhh.....hhh....", "..hhh.......hhh...", "..................", "..................", ], }, // 6 — Ensemble (48-55): choir trio — three little heads singing. gm6: { palette: { H: [70, 110, 160], h: [45, 75, 115], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], R: [200, 130, 120], r: [150, 90, 80], W: [220, 230, 245], O: [220, 230, 245], P: [15, 25, 40], E: [255, 255, 255], }, paletteLight: { H: [90, 130, 180], h: [60, 90, 130], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], R: [200, 130, 120], r: [150, 90, 80], W: [200, 215, 235], O: [200, 215, 235], P: [15, 25, 40], E: [255, 255, 255], }, spriteA: [ "...HHH..HHH..HHH..", "..HHHH.HHHH.HHHH..", "..hSSh.hSSh.hSSh..", "..SEES.SEES.SEES..", "..SSSS.SSSS.SSSS..", "..SMMS.SMMSO.SMMS.", "..SSSS.SSSS.SSSS..", "...WW...WW...WW...", "..RRRR.RRRR.RRRR..", ".RRRRR.RRRR.RRRRR.", ".RRRRRRRRRRRRRRRR.", ".RRRRRRRRRRRRRRRR.", "..rrrrrrrrrrrrrr..", "..rr.rr.rr.rr.rr..", "..rr.rr.rr.rr.rr..", "..hh.hh.hh.hh.hh..", "..hh.hh.hh.hh.hh..", "..................", "..................", "..................", "..................", "..................", ], spriteB: [ "...HHH..HHH..HHH..", "..HHHH.HHHH.HHHH..", "..hSSh.hSSh.hSSh..", "..SEES.SEES.SEES..", "..SSSS.SSSS.SSSS..", "..SNNS.SNNSO.SNNS.", "..SSSS.SSSS.SSSS..", "...WW...WW...WW...", "..RRRR.RRRR.RRRR..", ".RRRRR.RRRR.RRRRR.", ".RRRRRRRRRRRRRRRR.", ".RRRRRRRRRRRRRRRR.", "..rrrrrrrrrrrrrr..", "..rr.rr.rr.rr.rr..", "..rr.rr.rr.rr.rr..", "..hh.hh.hh.hh.hh..", "..hh.hh.hh.hh.hh..", "..................", "..................", "..................", "..................", "..................", ], }, // 7 — Brass (56-63): trumpeter with a flared horn bell + valves. gm7: { palette: { H: [110, 70, 40], h: [75, 47, 25], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [240, 200, 70], g: [180, 140, 40], W: [255, 245, 180], O: [255, 245, 180], P: [20, 12, 8], E: [255, 255, 255], }, paletteLight: { H: [110, 70, 40], h: [75, 47, 25], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [230, 185, 55], g: [170, 130, 35], W: [240, 225, 150], O: [240, 225, 150], P: [20, 12, 8], E: [255, 255, 255], }, spriteA: [ "...HHHHHHHH.......", "..HHHHHHHHHH......", ".HHEESSSSEEHH.....", ".HHEESSSSEEHH.....", ".HHSSSSSSSSHH.....", "..SSSMMMMSS.......", "..SSSSLLSS.GGG....", "...SSSSSO.GGGGG...", "....GgGgGgGGGGG...", "...GGGGGGGGGGGG...", "...GgGgGgGGGGG....", "....G.G.G.GGG.....", "....g.g.g.G.......", "..................", "..................", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", ".hhh........hhh...", "..................", "..................", ], spriteB: [ "...HHHHHHHH.......", "..HHHHHHHHHH......", ".HHEESSSSEEHH.....", ".HHEESSSSEEHH.....", ".HHSSSSSSSSHH.....", "..SSSNNNNSS.......", "..SSSSLLSS..GGG...", "...SSSSSO..GGGGG..", "....GgGgGgGGGGGG..", "...GGGGGGGGGGGGG..", "...GgGgGgGGGGGG...", "....G.G.G.GGG.....", "....g.g.g.G.......", "..................", "..................", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", ".hhh........hhh...", "..................", "..................", ], }, // 8 — Reed (64-71): sax/clarinet player, reed pipe with keys down side. gm8: { palette: { H: [60, 60, 70], h: [40, 40, 50], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [220, 190, 90], g: [160, 135, 55], W: [240, 220, 130], O: [240, 220, 130], P: [15, 15, 20], E: [255, 255, 255], }, paletteLight: { H: [80, 80, 90], h: [55, 55, 65], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [210, 175, 75], g: [150, 125, 45], W: [225, 200, 110], O: [225, 200, 110], P: [15, 15, 20], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSMMMMSSS......", "...SSSLLSSO.......", "....SSSSGG........", ".....SSGGGg.......", ".......GGGg.......", ".......GgGg.......", ".......GGGg.......", "......GgGGg.......", "......GGGGg.......", ".....GgGGGg.......", ".....GGGGGg.......", ".....GGGGGg.......", "......gggGG.......", ".......GGGG.......", "...hhh..GG.hhh....", "..hhh......hhh....", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSNNNNSSS......", "...SSSLLSSO.......", "....SSSSGG........", ".....SSGGGg.......", ".......GGGg.......", ".......GgGg.......", ".......GGGg.......", "......GGGGg.......", "......GgGGg.......", ".....GGGGGg.......", ".....GgGGGg.......", ".....GGGGGg.......", "......gggGG.......", ".......GGGG.......", "...hhh..GG.hhh....", "..hhh......hhh....", "..................", ], }, // 9 — Pipe (72-79): pan-flute / flute player, row of pipes below face. gm9: { palette: { H: [90, 130, 90], h: [60, 95, 60], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [190, 150, 90], g: [140, 105, 60], W: [220, 230, 210], O: [220, 230, 210], P: [15, 25, 15], E: [255, 255, 255], }, paletteLight: { H: [110, 150, 110], h: [75, 110, 75], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [185, 145, 85], g: [135, 100, 55], W: [205, 215, 195], O: [205, 215, 195], P: [15, 25, 15], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSSMMMMSS......", "...SSSSLLSSO......", "....SSSSSSS.......", "..GGGGGGGGGG......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GGGGGGGGGg......", "...GgGgGgGg.......", "....GgGgGg........", ".....GgGg.........", "...hhh...hhh......", "..hhh.....hhh.....", "..................", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSSNNNNSS......", "...SSSSLLSSO......", "....SSSSSSS.......", "..GGGGGGGGGG......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GgGgGgGgGg......", "..GGGGGGGGGg......", "...GgGgGgGg.......", "....GgGgGg........", ".....GgGg.........", "...hhh...hhh......", "..hhh.....hhh.....", "..................", "..................", ], }, // 10 — Synth Lead (80-87): neon shades + a jagged lightning lead bolt. gm10: { palette: { H: [180, 40, 160], h: [120, 25, 110], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [40, 30, 50], W: [120, 255, 220], Z: [255, 240, 90], O: [255, 240, 90], P: [10, 10, 20], E: [120, 255, 220], }, paletteLight: { H: [190, 60, 170], h: [130, 35, 120], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [50, 40, 60], W: [40, 200, 170], Z: [230, 200, 50], O: [230, 200, 50], P: [10, 10, 20], E: [40, 200, 170], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..GGGGGGGGGGGG....", "..GEEGGGGGGEEG....", "..GEEGGGGGGEEG....", "..SSSSSSSSSSSS....", "...SSMMMMMMSS.....", "...SSSLLLLSSO.....", "....SSSSSSSS......", ".......Z..........", "......ZZ..........", ".....ZZ...........", "....ZZZZ..........", "......ZZ..........", ".....ZZ...........", "....ZZ............", "...hhh....hhh.....", "..hhh......hhh....", "..................", "..................", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHHHHHHHHHHH....", "..GGGGGGGGGGGG....", "..GEEGGGGGGEEG....", "..GEEGGGGGGEEG....", "..SSSSSSSSSSSS....", "...SSNNNNNNSS.....", "...SSSLLLLSSO.....", "....SSSSSSSS......", "........Z.........", ".......ZZ.........", "......ZZ..........", ".....ZZZZ.........", ".......ZZ.........", "......ZZ..........", ".....ZZ...........", "...hhh....hhh.....", "..hhh......hhh....", "..................", "..................", "..................", ], }, // 11 — Synth Pad (88-95): dreamy cloud head, soft pastel swells. gm11: { palette: { H: [120, 110, 200], h: [85, 78, 150], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], W: [200, 200, 245], w: [160, 160, 215], C: [180, 220, 255], O: [200, 200, 245], P: [20, 18, 40], E: [255, 255, 255], }, paletteLight: { H: [140, 130, 210], h: [100, 92, 160], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], W: [210, 210, 250], w: [170, 170, 225], C: [160, 205, 245], O: [210, 210, 250], P: [20, 18, 40], E: [255, 255, 255], }, spriteA: [ "....CC..CC..CC....", "...CCCCCCCCCCC....", "..WWWWWWWWWWWW....", ".WWWWWWWWWWWWWW...", ".WWHHHHHHHHHHWW...", "..HHEEHHHHEEHH....", "..HHEEHHHHEEHH....", "..HHHHHHHHHHHH....", "..HHHHMMMMHHHH....", "..wwHHLLLLHHww....", "...wwHHHHHHww.....", "....wwwwwwwwO.....", "...WWWWWWWWWW.....", "..WWWWWWWWWWWW....", "..wwWWWWWWWWww....", "...wwwwwwwwww.....", "....ww....ww......", "....ww....ww......", "..................", "..................", "..................", "..................", ], spriteB: [ "...CC..CC..CC.....", "..CCCCCCCCCCC.....", "..WWWWWWWWWWWW....", ".WWWWWWWWWWWWWW...", ".WWHHHHHHHHHHWW...", "..HHEEHHHHEEHH....", "..HHEEHHHHEEHH....", "..HHHHHHHHHHHH....", "..HHHHNNNNHHHH....", "..wwHHLLLLHHww....", "...wwHHHHHHww.....", "....wwwwwwwwO.....", "...WWWWWWWWWW.....", "..WWWWWWWWWWWW....", "..wwWWWWWWWWww....", "...wwwwwwwwww.....", "....ww....ww......", "....ww....ww......", "..................", "..................", "..................", "..................", ], }, // 12 — Synth FX (96-103): sparkle-burst crystal sprite. gm12: { palette: { H: [40, 160, 170], h: [25, 110, 120], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], W: [180, 255, 250], Z: [255, 255, 200], C: [120, 230, 230], O: [255, 255, 200], P: [10, 30, 35], E: [255, 255, 255], }, paletteLight: { H: [50, 170, 180], h: [35, 120, 130], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], W: [150, 230, 225], Z: [235, 220, 120], C: [100, 200, 200], O: [235, 220, 120], P: [10, 30, 35], E: [255, 255, 255], }, spriteA: [ "....Z....Z..Z.....", "..Z...ZZ......Z...", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEEHHHHEEHH....", "..HHEEHHHHEEHH....", "..HHHHHHHHHHHH....", "..CCCCMMMMCCCC....", "..CCCCLLLLCCCO....", "...CCCCCCCCCC.....", "..WWWCWCWCWWW.....", ".WCWCWCWCWCWCW....", "..WWWCWCWCWWW.....", "...CWCWCWCWC......", "....CWCWCWC.......", ".....CWCWC........", "..Z....C....Z.....", "...Z..ZZZ..Z......", "..................", "..................", "..................", "..................", ], spriteB: [ "..Z...Z....Z......", "....Z....ZZ...Z...", "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEEHHHHEEHH....", "..HHEEHHHHEEHH....", "..HHHHHHHHHHHH....", "..CCCCNNNNCCCC....", "..CCCCLLLLCCCO....", "...CCCCCCCCCC.....", "..WCWCWCWCWCW.....", ".WWWCWCWCWCWWW....", "..WCWCWCWCWCW.....", "...CWCWCWCWC......", "....CWCWCWC.......", ".....CWCWC........", "...Z...C...Z......", "..Z..ZZZ....Z.....", "..................", "..................", "..................", "..................", ], }, // 13 — Ethnic (104-111): sitar player, long-necked gourd lute. gm13: { palette: { H: [150, 60, 30], h: [105, 42, 20], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [200, 150, 90], g: [150, 105, 55], W: [240, 230, 200], O: [240, 230, 200], P: [20, 10, 5], E: [255, 255, 255], }, paletteLight: { H: [150, 60, 30], h: [105, 42, 20], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [195, 145, 85], g: [145, 100, 50], W: [220, 210, 175], O: [220, 210, 175], P: [20, 10, 5], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSMMMMMMSS.....", "...SSSLLLLSSS.....", "....SSSSSSSS.G....", ".....SSSSS.GG.....", "..........GG......", ".........GG.......", "........GG........", ".......GGGG.......", "......GGGGGG......", ".....GGGOOGGG.....", "....GGGGGGGGGG....", "....GGGGGGGGGG....", ".....GGGGGGGG.....", "......gGGGGg......", "...hhh....hhh.....", "..hhh......hhh....", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSNNNNNNSS.....", "...SSSLLLLSSS.....", "....SSSSSSSS.G....", ".....SSSSS.GG.....", "..........GG......", ".........GG.......", "........GG........", ".......GGGG.......", "......GGGGGG......", ".....GGGOOGGG.....", "....GGGGGGGGGG....", "....GGGGGGGGGG....", ".....GGGGGGGG.....", "......gGGGGg......", "...hhh....hhh.....", "..hhh......hhh....", "..................", ], }, // 14 — Percussive (112-119): drummer with a taiko/drum + sticks. gm14: { palette: { H: [60, 55, 60], h: [40, 36, 40], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], D: [200, 60, 50], d: [150, 40, 35], R: [230, 220, 200], W: [240, 230, 200], O: [240, 230, 200], P: [15, 12, 15], E: [255, 255, 255], }, paletteLight: { H: [80, 75, 80], h: [55, 50, 55], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], D: [200, 60, 50], d: [150, 40, 35], R: [215, 205, 185], W: [220, 210, 180], O: [220, 210, 180], P: [15, 12, 15], E: [255, 255, 255], }, spriteA: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSMMMMMMSS.....", "...SSSLLLLSSS.....", "....SSSSSSSS......", "..W..........W....", "..W...........W...", "..DDDDDDDDDDDD....", ".DRRRRRRRRRRRRD...", ".DRRRRRRRRRRORD...", ".DRRRRRRRRRRRRD...", "..DDDDDDDDDDDD....", "..dddddddddddd....", "..dd........dd....", "..dd........dd....", "..hh........hh....", "..................", "..................", "..................", ], spriteB: [ "....HHHHHHHH......", "...HHHHHHHHHH.....", "..HHEESSSSEEHH....", "..HHEESSSSEEHH....", "..HHSSSSSSSSHH....", "...SSNNNNNNSS.....", "...SSSLLLLSSS.....", "....SSSSSSSS......", "...W........W.....", "..W..........W....", "..DDDDDDDDDDDD....", ".DRRRRRRRRRRRRD...", ".DRORRRRRRRRRD....", ".DRRRRRRRRRRRRD...", "..DDDDDDDDDDDD....", "..dddddddddddd....", "..dd........dd....", "..dd........dd....", "..hh........hh....", "..................", "..................", "..................", ], }, // 15 — Sound FX (120-127): retro radio/TV head broadcasting static. gm15: { palette: { H: [80, 80, 95], h: [55, 55, 70], S: [240, 205, 175], M: [120, 60, 70], N: [170, 80, 95], L: [150, 80, 90], G: [40, 45, 55], W: [120, 230, 130], Z: [200, 210, 220], O: [120, 230, 130], P: [10, 12, 18], E: [120, 230, 130], }, paletteLight: { H: [100, 100, 115], h: [70, 70, 85], S: [240, 205, 175], M: [120, 50, 60], N: [160, 70, 85], L: [140, 70, 80], G: [50, 55, 65], W: [60, 180, 80], Z: [180, 190, 200], O: [60, 180, 80], P: [10, 12, 18], E: [60, 180, 80], }, spriteA: [ ".......W..........", "....W..W..W.......", "..HHHHHHHHHHHH....", "..HHHHHHHHHHHH....", "..HGGGGGGGGGGH....", "..HGEZGGGGZEGH....", "..HGEZGGGGZEGH....", "..HGGGGGGGGGGH....", "..HGGMMMMMMGGH....", "..HGGGLLLLGGOH....", "..HGGGGGGGGGGH....", "..HHHHHHHHHHHH....", "..HH.WW..WW.HH....", "..HHHHHHHHHHHH....", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", "..................", "..................", "..................", "..................", ], spriteB: [ "....W..W..........", ".......W....W.....", "..HHHHHHHHHHHH....", "..HHHHHHHHHHHH....", "..HGGGGGGGGGGH....", "..HGEZGGGGZEGH....", "..HGEZGGGGZEGH....", "..HGGGGGGGGGGH....", "..HGGNNNNNNGGH....", "..HGGGLLLLGGOH....", "..HGGGGGGGGGGH....", "..HHHHHHHHHHHH....", "..HH.WW..WW.HH....", "..HHHHHHHHHHHH....", "...hhh....hhh.....", "...hhh....hhh.....", "..hhh......hhh....", "..hhh......hhh....", "..................", "..................", "..................", "..................", ], }, }; // GM program (0..127) → its family bandmate key. 16 families of 8 each. function gmFamilyKey(program) { const p = Math.max(0, Math.min(127, program | 0)); return "gm" + Math.floor(p / 8); } // Resolve any voice to the sprite table that holds its character. AC // wavetypes live in CHARACTER_SPRITES; GM voices use GM_FAMILY_SPRITES. function bandmateSprite(key) { return CHARACTER_SPRITES[key] || GM_FAMILY_SPRITES[key] || null; } // === GLOBAL VOICE CHOOSER === // One continuous horizontal strip of every selectable voice: all 128 General // MIDI programs FIRST (index 0..127, in GM order), then the 8 AC native // wavetypes at the END (index 128..135). This is the single "global voice // slider" — Left/Right arrows step through it, and it stays in sync with the // number-row digit GM picker and the wave-button row (each just moves the // same selection). voiceCount = 136. The chooser owns no audio path of its // own: stepping into GM range calls setGmProgram(), stepping into AC range // calls setWave() — the exact same routes the existing pickers use. const VOICE_GM_COUNT = 128; const VOICE_TOTAL = VOICE_GM_COUNT + 8; // 128 GM + 8 AC wavetypes = 136 let voiceIndex = 0; // 0..135 — current position in the combined strip let voiceScroll = 0; // animated scroll position (eases toward voiceIndex) let chooserVisible = false; // shown briefly after a step, then fades let chooserUntil = 0; // frame at which the chooser auto-hides // Voice index 0..135 → its bandmate sprite key (gm0..gm15 or an AC wavetype). function voiceSpriteKey(idx) { if (idx < VOICE_GM_COUNT) return gmFamilyKey(idx); return wavetypes[idx - VOICE_GM_COUNT] || "sine"; } // Voice index → human label (GM program name, or the AC wavetype name). function voiceLabel(idx) { if (idx < VOICE_GM_COUNT) { return String(idx + 1).padStart(3, "0") + " " + (GM_PROGRAM_NAMES[idx] || ""); } return "AC " + (wavetypes[idx - VOICE_GM_COUNT] || "sine"); } // Recompute voiceIndex from the live selection state so the chooser always // agrees with whatever picked the current voice (digits, wave buttons, Tab). // GM selected → its program index; otherwise the AC wavetype's slot. function syncVoiceIndex() { if (gmProgram !== null) { voiceIndex = Math.max(0, Math.min(VOICE_GM_COUNT - 1, gmProgram)); } else { const wi = wavetypes.indexOf(wave); voiceIndex = VOICE_GM_COUNT + (wi < 0 ? 0 : wi); } } // Apply a chooser index to the actual voice state, reusing the existing // selection routes so notes really play it. GM range → setGmProgram (sets // gmProgram + emits Program Change); AC range → setWave (clears GM, switches // wave). Clamped to [0, VOICE_TOTAL). Surfaces the chooser overlay for ~2.5s. function selectVoiceIndex(idx, sound, system) { const next = Math.max(0, Math.min(VOICE_TOTAL - 1, idx | 0)); voiceIndex = next; chooserVisible = true; chooserUntil = frame + 150; // ~2.5s, matches gmNotice dwell if (next < VOICE_GM_COUNT) { gmPassthrough = false; setGmProgram(next, sound, system); } else { setWave(wavetypes[next - VOICE_GM_COUNT], sound); } } // Step the chooser by ±1 (clamped — safer than wrapping so you can't blow // past either end accidentally). Bound to Left/Right arrows. function stepVoice(delta, sound, system) { syncVoiceIndex(); // re-anchor in case digits/buttons moved us since last step selectVoiceIndex(voiceIndex + delta, sound, system); } // Render one bandmate sprite into an arbitrary box at an arbitrary block // scale, dimmed by `dim` (0..1). A trimmed-down sibling of drawBandmate used // only by the chooser strip: it skips the eye-tracking / mouth-animation / // note-emission machinery (purely a still preview) so neighbors stay cheap, // but it honors the same palette/theme + O/E/P transparency rules so the art // reads identically. `bob` shifts the sprite up by a pixel on alternate // frames for a subtle idle nod on the centered (focused) entry. function drawChooserSprite(key, ink, box, cx, baseY, block, dark, dim, bob) { const char = bandmateSprite(key); if (!char) return; const palette = dark ? char.palette : char.paletteLight; const sprite = (Math.floor(frame / 16) % 2 === 0) ? char.spriteA : char.spriteB; const spW = sprite[0].length; const spH = sprite.length; const sx = Math.floor(cx - (spW * block) / 2); const sy = baseY - spH * block - (bob ? ((frame >> 3) & 1) * block : 0); const skin = palette["S"] || [240, 205, 175]; for (let row = 0; row < spH; row++) { const lineStr = sprite[row]; for (let col = 0; col < spW; col++) { let ch = lineStr[col]; if (ch === "." || ch === " " || ch === "O" || ch === "P") continue; // Eyes render as flat skin-tone whites (no pupil tracking in preview). let color = ch === "E" ? skin : palette[ch]; if (!color) continue; let r = color[0], g = color[1], b = color[2]; if (dim < 1) { r = Math.round(r * dim); g = Math.round(g * dim); b = Math.round(b * dim); } ink(r, g, b); box(sx + col * block, sy + row * block, block, block, true); } } } // Draw the GLOBAL VOICE CHOOSER strip — the centered current voice big, with // its immediate neighbors peeking in at the sides, plus the program/wave name // and a tiny "n / 136" position counter. It eases voiceScroll toward // voiceIndex each frame for a smooth slide, and fades out a beat after the // last step (chooserUntil). Drawn as a top overlay near the end of paint. function drawVoiceChooser(ink, box, write, w, h, dark) { // Ease the animated scroll toward the live index (fract carries the slide). voiceScroll += (voiceIndex - voiceScroll) * 0.3; if (Math.abs(voiceIndex - voiceScroll) < 0.01) voiceScroll = voiceIndex; if (frame >= chooserUntil) { chooserVisible = false; return; } const remaining = chooserUntil - frame; const alpha = Math.min(255, Math.round(remaining * 2.2)); // fade over last ~1.9s // Strip geometry: a band centered vertically, tall enough for one ~22px // sprite at block scale 3 (≈66px) plus a name line. Neighbors sit at the // quarter points; the focus sits dead center, larger. const stripH = 86; const stripY = Math.max(0, Math.floor(h / 2 - stripH / 2)); const cx = w / 2; const baseY = stripY + 70; // sprite feet line const slot = Math.min(96, Math.floor(w / 3)); // horizontal spacing per entry // Backdrop band so the art reads over any grid behind it. ink(dark ? 8 : 235, dark ? 10 : 238, dark ? 16 : 245, Math.floor(alpha * 0.82)); box(0, stripY, w, stripH, true); ink(dark ? 60 : 150, dark ? 120 : 170, dark ? 200 : 210, Math.floor(alpha * 0.7)); box(0, stripY, w, 1, true); box(0, stripY + stripH - 1, w, 1, true); // Draw a few entries on each side of the focus (centered on voiceScroll so // the whole row slides). Offsets -2..+2 cover the visible peeking neighbors. for (let off = -2; off <= 2; off++) { const idx = Math.round(voiceScroll) + off; if (idx < 0 || idx >= VOICE_TOTAL) continue; // Position relative to the eased scroll so motion is continuous. const ex = cx + (idx - voiceScroll) * slot; if (ex < -slot || ex > w + slot) continue; const focused = idx === voiceIndex; const block = focused ? 3 : 2; // Distance-based dim so neighbors recede; focus is full-bright. const dist = Math.abs(idx - voiceScroll); const dim = Math.max(0.32, 1 - dist * 0.34) * (alpha / 255); drawChooserSprite(voiceSpriteKey(idx), ink, box, ex, baseY, block, dark, dim, focused); } // Focus highlight ring under the centered sprite. ink(dark ? 120 : 40, dark ? 200 : 120, dark ? 255 : 220, Math.floor(alpha * 0.8)); box(Math.floor(cx - 30), baseY + 2, 60, 1, true); // Voice name (centered) + an "AC instruments ◂ start" / "MIDI" hint. const label = voiceLabel(voiceIndex); const lw = label.length * 6; ink(dark ? 230 : 20, dark ? 245 : 30, dark ? 255 : 60, alpha); write(label, { x: Math.max(2, Math.floor(cx - lw / 2)), y: stripY + 4, font: "font_1" }); // Position counter "n/136" + range tag, small, bottom-right of the strip. const region = voiceIndex < VOICE_GM_COUNT ? "MIDI" : "AC"; const tag = `${voiceIndex + 1}/${VOICE_TOTAL} ${region}`; ink(dark ? 130 : 110, dark ? 150 : 120, dark ? 180 : 140, alpha); write(tag, { x: Math.max(2, w - tag.length * 6 - 3), y: stripY + stripH - 11, font: "font_1" }); // Arrow affordances at the strip edges (dim near the clamped ends). const atStart = voiceIndex === 0; const atEnd = voiceIndex === VOICE_TOTAL - 1; ink(dark ? 200 : 60, dark ? 220 : 80, dark ? 255 : 120, Math.floor(alpha * (atStart ? 0.25 : 0.9))); write("◂", { x: 4, y: baseY - 36, font: "font_1" }); ink(dark ? 200 : 60, dark ? 220 : 80, dark ? 255 : 120, Math.floor(alpha * (atEnd ? 0.25 : 0.9))); write("▸", { x: w - 10, y: baseY - 36, font: "font_1" }); } // Floating music-note particles emitted by the active bandmate when notes // play. Pure visual layer — additive, max ~80, drift up + sway, fade out. let musicNotes = []; let lastTrailEmit = {}; // key -> last frame that emitted a note for this key function emitMusicNote(originX, originY, color) { if (musicNotes.length > 80) musicNotes.shift(); const ang = (Math.random() - 0.5) * 0.6; musicNotes.push({ x: originX + (Math.random() - 0.5) * 4, y: originY, vx: ang * 1.2, vy: -0.7 - Math.random() * 0.5, swayPhase: Math.random() * Math.PI * 2, swayAmp: 1 + Math.random() * 1.5, glyph: Math.floor(Math.random() * 3), // 0=quarter, 1=eighth, 2=beamed color: [color[0], color[1], color[2]], life: 0, max: 55 + Math.floor(Math.random() * 20), }); } function updateMusicNotes() { for (let i = musicNotes.length - 1; i >= 0; i--) { const n = musicNotes[i]; n.life++; n.swayPhase += 0.18; n.x += n.vx + Math.sin(n.swayPhase) * 0.3; n.y += n.vy; n.vy += 0.006; if (n.life >= n.max) musicNotes.splice(i, 1); } } function drawMusicNotes(ink, box) { for (const n of musicNotes) { const t = n.life / n.max; const alpha = Math.floor((1 - t * t) * 230); if (alpha <= 0) continue; const c = n.color; const x = Math.floor(n.x); const y = Math.floor(n.y); // Note head — 3×2 filled blob ink(c[0], c[1], c[2], alpha); box(x, y, 3, 2, true); // Stem rising from head box(x + 2, y - 5, 1, 5, true); // Flag (eighth/beamed have a flag at the top) if (n.glyph >= 1) { box(x + 3, y - 5, 2, 1, true); box(x + 3, y - 4, 1, 1, true); } if (n.glyph === 2) { // Beamed pair — extra small flag box(x + 3, y - 3, 2, 1, true); } } } // Draws the procedural bandmate for the given wave type. Returns the // origin point for music notes (mouth/'O' marker) so the caller can // emit particles from the right spot. function drawBandmate(wave, ink, box, ctx) { const char = bandmateSprite(wave); if (!char) return null; const { dark, frame, activeCount, lastKeyFrame, hoverX, hoverY, centerX, topY, bottomY } = ctx; const palette = dark ? char.palette : char.paletteLight; const playing = activeCount > 0; const recent = (frame - lastKeyFrame) < 30; // Sway speed: faster when actively playing, mid when recently touched, // slow idle otherwise. Sawtooth twitches faster (sinister jitter). const fastSwap = wave === "sawtooth" ? 4 : 6; const idleSwap = wave === "sawtooth" ? 9 : 16; const swapEvery = playing ? fastSwap : (recent ? Math.floor((fastSwap + idleSwap) / 2) : idleSwap); const sprite = (Math.floor(frame / swapEvery) % 2 === 0) ? char.spriteA : char.spriteB; const block = 2; const spW = sprite[0].length; const spH = sprite.length; const spriteW = spW * block; const spriteH = spH * block; const sx = Math.floor(centerX - spriteW / 2); const sy = Math.max(topY + 2, bottomY - spriteH - 2); // Eye-tracking: pupils nudge toward the cursor (or center if no hover). // Clamp to ±1 px within the eye-white block so they stay inside the eye. const cx = sx + spriteW / 2; const cy = sy + spriteH / 2; const dx = hoverX >= 0 ? (hoverX - cx) : 0; const dy = hoverY >= 0 ? (hoverY - cy) : 0; const pupilDX = Math.max(-1, Math.min(1, Math.round(dx / 40))); const pupilDY = Math.max(-1, Math.min(1, Math.round(dy / 50))); const pluckFresh = (frame - lastKeyFrame) < 10; const showActiveMouth = playing || (frame - lastKeyFrame) < 8; // Pass 0: locate the music-note emission origin. Prefer an explicit // 'O' marker in either frame; fall back to the first M/N (mouth) so // characters drawn without an explicit O still emit from the face. let mouthX = sx + Math.floor(spriteW / 2); let mouthY = sy + Math.floor(spriteH * 0.4); let mouthFound = false; for (const grid of [char.spriteA, char.spriteB]) { if (mouthFound) break; for (let r = 0; r < grid.length; r++) { const idxO = grid[r].indexOf("O"); if (idxO >= 0) { mouthX = sx + idxO * block; mouthY = sy + r * block; mouthFound = true; break; } } } if (!mouthFound) { for (let r = 0; r < sprite.length; r++) { const lineStr = sprite[r]; const idxM = lineStr.indexOf("M") >= 0 ? lineStr.indexOf("M") : lineStr.indexOf("N"); if (idxM >= 0) { mouthX = sx + idxM * block; mouthY = sy + r * block; break; } } } for (let row = 0; row < spH; row++) { const lineStr = sprite[row]; for (let col = 0; col < spW; col++) { let ch = lineStr[col]; if (ch === "." || ch === " ") continue; if (ch === "E" || ch === "P") continue; // drawn in pass 2 if (ch === "O") continue; // origin marker — invisible if (ch === "M" && showActiveMouth) { ch = "N"; } else if (ch === "N" && !showActiveMouth) { ch = "M"; } let color = palette[ch]; if (!color) continue; // Fresh-pluck shimmer on instrument detail. if ((ch === "=" || ch === "Z") && pluckFresh) { const bright = palette["+"]; if (bright) color = bright; } ink(color[0], color[1], color[2]); box(sx + col * block, sy + row * block, block, block, true); } } // Pass 2: eye whites + pupil overlay so eyes visibly track the cursor. const skinPal = palette["S"] || [240, 200, 170]; const pupilPal = palette["P"] || [20, 10, 30]; for (let row = 0; row < spH; row++) { const lineStr = sprite[row]; for (let col = 0; col < spW; col++) { if (lineStr[col] !== "E") continue; // Eye-white background (skin-tone) ink(skinPal[0], skinPal[1], skinPal[2]); box(sx + col * block, sy + row * block, block, block, true); } } // Cluster eye Es into per-eye centers (left half / right half of the // sprite) so each eye gets one pupil rather than per-cell. const eyeCells = []; for (let row = 0; row < spH; row++) { const lineStr = sprite[row]; for (let col = 0; col < spW; col++) { if (lineStr[col] === "E") eyeCells.push({ row, col }); } } if (eyeCells.length > 0) { // Group by clusters with the gap heuristic (same row, contiguous cols // form one eye; otherwise separate eyes). const halfCol = spW / 2; let lEye = null, rEye = null; for (const cell of eyeCells) { const target = cell.col < halfCol ? "l" : "r"; const slot = target === "l" ? lEye : rEye; if (!slot) { if (target === "l") lEye = { rSum: cell.row, cSum: cell.col, n: 1 }; else rEye = { rSum: cell.row, cSum: cell.col, n: 1 }; } else { slot.rSum += cell.row; slot.cSum += cell.col; slot.n += 1; } } const drawPupil = (eye) => { if (!eye) return; const cr = eye.rSum / eye.n; const cc = eye.cSum / eye.n; const ex = sx + Math.round(cc * block) + pupilDX; const ey = sy + Math.round(cr * block) + pupilDY; ink(pupilPal[0], pupilPal[1], pupilPal[2]); box(ex, ey, 1, 1, true); }; drawPupil(lEye); drawPupil(rEye); } return { mouthX, mouthY }; } function paint({ wipe, ink, box, line, write, screen, sound, system, trackpad, pressures, wifi }) { frame++; usbMidiRecent = usbMidiRecent.filter((entry) => entry.until > frame); // FPS tracking const now = performance.now(); if (fpsLastTime > 0) { fpsAccum += now - fpsLastTime; fpsSamples++; if (fpsSamples >= 30) { fpsDisplay = Math.round(30000 / fpsAccum); fpsAccum = 0; fpsSamples = 0; } } fpsLastTime = now; const activeCount = Object.keys(sounds).length; const w = screen.width; const h = screen.height; const CH = 6; // char width at size 1 (font_1 = 6x10) const mic = sound?.microphone || {}; let usbMidiStatus = readUsbMidiStatus(system); sampleLoaded = (mic.sampleLength || 0) > 0; globalThis.__screenW = w; // expose for act() globalThis.__screenH = h; const typecSignature = (system?.typec || []) .map((port) => port.port + ":" + port.powerRole + ":" + port.dataRole) .join("|"); const typecChanged = typecSignature !== usbMidiTypecSignature; if (typecChanged) { usbMidiTypecSignature = typecSignature; } if (usbMidiStatus?.enabled && !usbMidiStatus?.active && frame >= usbMidiNextRefreshFrame) { const typecDevice = (system?.typec || []).some((port) => port.dataRole === "device"); if (typecChanged && typecDevice) { usbMidiStatus = system?.usbMidi?.refresh?.() || usbMidiStatus; usbMidiNextRefreshFrame = frame + 180; } } // Trackpad FX: per-effect X/Y routing with stacking. if (trackpadFX && trackpad) { let echoDirty = false; let crushDirty = false; let volDirty = false; let driveDirty = false; let wobbleDirty = false; if (trackpad.dx !== 0) { const dxNorm = trackpad.dx / Math.max(1, w); if (trackpadEffectBindings.echo.x) { echoDirty = setEchoMixValue(echoMix + dxNorm * 3, sound, false) || echoDirty; } if (trackpadEffectBindings.crush.x) { crushDirty = setBitcrushMixValue(bitcrushMix + dxNorm * 3, sound, false) || crushDirty; } if (trackpadEffectBindings.pitch.x) { setPitchShiftValue(pitchShift + dxNorm, false); } if (trackpadEffectBindings.volume.x) { volDirty = setMasterVolMixValue(masterVolMix + dxNorm * 3, sound, false) || volDirty; } if (trackpadEffectBindings.drive.x) { driveDirty = setDriveMixValue(driveMix + dxNorm * 3, sound, false) || driveDirty; } if (trackpadEffectBindings.wobble.x) { wobbleDirty = setWobbleMixValue(wobbleMix + dxNorm * 3, sound, false) || wobbleDirty; } } if (trackpad.dy !== 0) { const dyNorm = -trackpad.dy / Math.max(1, h); if (trackpadEffectBindings.echo.y) { echoDirty = setEchoMixValue(echoMix + dyNorm * 3, sound, false) || echoDirty; } if (trackpadEffectBindings.crush.y) { crushDirty = setBitcrushMixValue(bitcrushMix + dyNorm * 3, sound, false) || crushDirty; } if (trackpadEffectBindings.pitch.y) { setPitchShiftValue(pitchShift + dyNorm, false); } if (trackpadEffectBindings.volume.y) { volDirty = setMasterVolMixValue(masterVolMix + dyNorm * 3, sound, false) || volDirty; } if (trackpadEffectBindings.drive.y) { driveDirty = setDriveMixValue(driveMix + dyNorm * 3, sound, false) || driveDirty; } if (trackpadEffectBindings.wobble.y) { wobbleDirty = setWobbleMixValue(wobbleMix + dyNorm * 3, sound, false) || wobbleDirty; } } if (echoDirty && frame % 3 === 0) sound?.room?.setMix?.(echoMix); if (crushDirty && frame % 3 === 0) sound?.glitch?.setMix?.(bitcrushMix); if (volDirty && frame % 3 === 0) sound?.volume?.setMix?.(masterVolMix * 2); if (driveDirty && frame % 3 === 0) sound?.drive?.setMix?.(driveMix); if (wobbleDirty && frame % 3 === 0) sound?.wobble?.setMix?.(wobbleMix); // Apply pitch shift to active voices — throttled to every 4th frame // and only when pitch actually changed if (frame % 4 === 0) applyPitchShiftToActiveSounds(false); } // Metronome tick (clock-synced) if (metronomeEnabled && metronomeBPM > 0) { const now = syncedNow(); const msPerBeat = 60000 / metronomeBPM; const beatNumber = Math.floor(now / msPerBeat); // Pendulum: smooth sinusoidal swing synced to beat phase (2-beat period) const beatPhase = (now % (msPerBeat * 2)) / (msPerBeat * 2); metronomePendulumAngle = Math.sin(beatPhase * Math.PI * 2); if (beatNumber !== metronomeBeatCount) { metronomeBeatCount = beatNumber; metronomeVisualPhase = 1.0; metronomeFlash = 1.0; const isDownbeat = (beatNumber % 4) === 0; sound.synth({ type: "square", tone: isDownbeat ? 1200 : 800, duration: 0.03, volume: isDownbeat ? 0.4 : 0.25, attack: 0.001, decay: 0.02, pan: 0, }); } if (metronomeVisualPhase > 0) metronomeVisualPhase = Math.max(0, metronomeVisualPhase - 0.08); if (metronomeFlash > 0) metronomeFlash = Math.max(0, metronomeFlash - 0.15); } // Re-check dark mode every ~10 seconds // dark mode is always on // Theme: dark after 7pm LA, light before const FG = dark ? 220 : 40; const FG_DIM = dark ? 140 : 100; const FG_MUTED = dark ? 80 : 150; const BAR_BG = dark ? [35, 20, 30] : [225, 220, 215]; const BAR_BORDER = dark ? [55, 35, 45] : [200, 195, 190]; const PAD_NORMAL = dark ? [28, 28, 30] : [250, 248, 244]; const PAD_SHARP = dark ? [18, 18, 20] : [235, 232, 228]; const PAD_OUTLINE = dark ? [50, 50, 55] : [210, 205, 200]; // Compute background color from active notes (held) + drum flashes (decaying) // — full-screen color response so drums have the same visual presence as tones. const activeKeys = Object.keys(sounds); // Tone layer: sum of colors for every held note let toneR = 0, toneG = 0, toneB = 0, toneWeight = 0; for (const key of activeKeys) { const noteName = KEY_TO_NOTE[key]; if (noteName) { const [letter] = parseNote(noteName); const nc = noteColor(letter); toneR += nc[0]; toneG += nc[1]; toneB += nc[2]; toneWeight++; } } // Drum layer: prune expired entries, then sum with per-entry alpha decay. // First 2 frames hold full intensity for a punchy hit, then linear fade. if (drumFlashes.length > 0) { drumFlashes = drumFlashes.filter(f => (frame - f.frame) < f.life); } let drumR = 0, drumG = 0, drumB = 0, drumWeight = 0; for (const f of drumFlashes) { const age = frame - f.frame; let alpha; if (age < 2) alpha = 1.0; else alpha = Math.max(0, 1 - (age - 2) / (f.life - 2)); drumR += f.color[0] * alpha; drumG += f.color[1] * alpha; drumB += f.color[2] * alpha; drumWeight += alpha; } if (toneWeight > 0 || drumWeight > 0) { // Tones count as full weight each; drums' contribution is their summed alpha. // Drums get a 1.2× weight multiplier so a single hit still reads on screen // even if multiple tones are held simultaneously. const totalWeight = toneWeight + drumWeight * 1.2; const r = (toneR + drumR * 1.2) / totalWeight; const g = (toneG + drumG * 1.2) / totalWeight; const b = (toneB + drumB * 1.2) / totalWeight; const mx = Math.max(r, g, b, 1); const sat = 255 / mx; bgTarget = [ Math.min(255, Math.floor(r * sat)), Math.min(255, Math.floor(g * sat)), Math.min(255, Math.floor(b * sat)), ]; if (toneWeight > 0) { // Held tone(s) — snap instantly so the blink reads bgColor[0] = bgTarget[0]; bgColor[1] = bgTarget[1]; bgColor[2] = bgTarget[2]; } else { // Drum-only — fast blend so rapid hits still read but single hits fade bgColor[0] += (bgTarget[0] - bgColor[0]) * 0.55; bgColor[1] += (bgTarget[1] - bgColor[1]) * 0.55; bgColor[2] += (bgTarget[2] - bgColor[2]) * 0.55; } } else { bgTarget = dark ? [0, 0, 0] : [255, 255, 255]; // Fade smoothly bgColor[0] += (bgTarget[0] - bgColor[0]) * 0.25; bgColor[1] += (bgTarget[1] - bgColor[1]) * 0.25; bgColor[2] += (bgTarget[2] - bgColor[2]) * 0.25; } wipe(Math.round(bgColor[0]), Math.round(bgColor[1]), Math.round(bgColor[2])); // Tablet mode change detection — beep + log if (system.tabletMode !== lastTabletMode && lastTabletMode !== null) { if (system.tabletMode) { sound?.synth({ type: "sine", tone: 880, duration: 0.12, volume: 0.2, attack: 0.005, decay: 0.1 }); sound?.synth({ type: "sine", tone: 1320, duration: 0.12, volume: 0.15, attack: 0.005, decay: 0.1 }); } else { sound?.synth({ type: "sine", tone: 1320, duration: 0.12, volume: 0.2, attack: 0.005, decay: 0.1 }); sound?.synth({ type: "sine", tone: 880, duration: 0.12, volume: 0.15, attack: 0.005, decay: 0.1 }); } system.writeFile?.("/mnt/tablet-mode.log", `${new Date().toISOString()} tablet=${system.tabletMode ? "on" : "off"}\n`); } lastTabletMode = system.tabletMode; // Tablet mode or Recital mode (F12): solid color only, no UI if (system.tabletMode || recitalMode) return; // Metronome flash border if (metronomeFlash > 0 && metronomeEnabled) { const fa = Math.floor(metronomeFlash * 120); const fw = Math.max(2, Math.floor(5 * metronomeFlash)); const db = (metronomeBeatCount % 4) === 0; if (db) ink(255, 100, 100, fa); else ink(180, 200, 255, fa); box(0, 0, w, fw, true); box(0, h - fw, w, fw, true); box(0, 0, fw, h, true); box(w - fw, 0, fw, h, true); } // === STATUS BAR === // Status text (notepat.com / midi / vol / fps / time / battery) sits // flush with the top of the screen at barY=3 so the whole row reads // as one continuous strip of readouts along the top edge. The QR // fills the space underneath (25×25 at scale=1, anchored at (2,2)). // topBarH stays tall enough (34) to fully contain the QR; everything // downstream (pads, waveform strip) anchors to `topBarH` so shrinking // the bar automatically gives the rest of the UI more room. const topBarH = 34; const barY = 3; // top-flush text row (matrix font at size=1 is ~7 px tall) ink(BAR_BG[0], BAR_BG[1], BAR_BG[2]); box(0, 0, w, topBarH, true); ink(BAR_BORDER[0], BAR_BORDER[1], BAR_BORDER[2]); line(0, topBarH - 1, w, topBarH - 1); // Reserve space for right-side indicators so left status text never overlaps. const reserveBatPct = system?.battery?.percent ?? -1; const reserveSysBrt = system?.brightness ?? -1; const reserveNow = new Date(syncedNow()); const reserveLaMs = reserveNow.getTime() - getLAOffset() * 3600000; const reserveLaDate = new Date(reserveLaMs); const reserveHh = reserveLaDate.getUTCHours(); const reserveMm = reserveLaDate.getUTCMinutes(); const reserveSs = reserveLaDate.getUTCSeconds(); const reserveAmpm = reserveHh >= 12 ? "p" : "a"; const reserveH12 = reserveHh % 12 || 12; const reserveTime = reserveH12 + ":" + (reserveMm < 10 ? "0" : "") + reserveMm + ":" + (reserveSs < 10 ? "0" : "") + reserveSs + reserveAmpm; let statusRightReserve = reserveTime.length * CH + 4 + 20 + 2 + 3 * CH; // time + vol if (reserveBatPct >= 0) statusRightReserve += 14 + (String(reserveBatPct).length + 1) * CH + 6; if (reserveSysBrt >= 0) statusRightReserve += 4 + 16 + 2 + 3 * CH; const statusRightLimit = Math.max(80, w - statusRightReserve - 8); // Left: QR code → notepat.com. Scale=1, version-1 with 2-module quiet // zone = 25×25 px. Taller top bar (54 px) leaves breathing room below // the QR for the label + status text without cramping. C side caches // the Reed-Solomon encoding so the inner module-grid blit is the only // per-frame cost. if (globalThis.qr) { globalThis.qr("https://notepat.com", 2, 2, 1); } const qrW = 28; // 25px QR + 2px left inset + 1px right padding before label const labelX = qrW + 4; // Wordmark at size=2 (matrix glyph advance = 8px): "notepat" = 7*8 = 56px, // ".com" follows at +56. The whole mark is ~14px tall, so it sits a touch // lower than the size-1 status row to stay vertically centered in the bar. const wmSize = 2; const wmCh = 4 * wmSize; // matrix glyph advance per char at this size const wmY = barY + 2; const labelW = 11 * wmCh; // "notepat.com" = 11 glyphs const npHovered = hoverX >= 0 && hoverX <= labelX + labelW && hoverY < topBarH; if (npHovered) { ink(255, 255, 255, 20); box(labelX - 2, 0, labelW + 2, topBarH, true); } ink(FG, FG, FG, npHovered ? 255 : 200); write("notepat", { x: labelX, y: wmY, size: wmSize, font: "matrix" }); ink(dark ? 200 : 180, dark ? 100 : 60, dark ? 140 : 120, npHovered ? 255 : 200); const dotComX = labelX + 7 * wmCh; write(".com", { x: dotComX, y: wmY, size: wmSize, font: "matrix" }); // Whole top-left zone (QR + label) becomes the click target so tapping // either the QR or the text jumps to prompt. globalThis.__npBtn = { x: 0, w: labelX + labelW, h: topBarH }; // NuPhy badge + live sensor gauge. Active when we've seen an analog // pressure value in the last 60 seconds. The gauge is two stacked // horizontal bars: // top: raw last-pressed value (fades with age) // bottom: per-key-sum of currently-smoothed pressure (live jiggle) // Gives a quick visual of the sensor input + our smoothing headroom. const analogAgeMs = syncedNow() - lastAnalogKeyAt; if (lastAnalogKeyAt > 0 && analogAgeMs < 60000) { const badgeX = dotComX + 4 * wmCh + 6; // clear the size-scaled ".com" // Label ink(160, 220, 255, 220); write("nuphy", { x: badgeX, y: barY, size: 1, font: "matrix" }); // Live smoothed-pressure sum across active analog notes let liveSmooth = 0, liveCount = 0; for (const k of Object.keys(sounds)) { const s = sounds[k]; if (s && s.__pSmooth !== undefined) { liveSmooth = Math.max(liveSmooth, s.__pSmooth); liveCount++; } } const gx = badgeX + 5 * 4 + 3; const gw = 26; const gy1 = 2, gy2 = 6; // two rows of 3px tall bars inside the 26px bar // Fade the "last press" bar with age over 2 seconds so repeated // presses refresh it visibly; leaves a faint echo of the last hit. const echoAlpha = Math.max(0, 1.0 - analogAgeMs / 2000) * 255; // Bar frames ink(60, 60, 80, 160); box(gx, gy1, gw, 3, true); box(gx, gy2, gw, 3, true); // Bar fills ink(140, 200, 255, echoAlpha); box(gx, gy1, Math.round(gw * lastAnalogPressure), 3, true); ink(80, 220, 140, 220); box(gx, gy2, Math.round(gw * liveSmooth), 3, true); // Tolerance marks (thresholds we care about: 0.1 + 0.7) ink(200, 150, 60, 180); box(gx + Math.round(gw * 0.10), gy1, 1, 7, true); ink(200, 80, 80, 180); box(gx + Math.round(gw * 0.70), gy1, 1, 7, true); } // Status area after notepat.com — auto-update indicator only (minimal) let statusX = dotComX + 4 * 4 + 6; // Shift statusX past the nuphy gauge when it's visible so indicators // don't overlap. if (lastAnalogKeyAt > 0 && analogAgeMs < 60000) statusX += 5 * 4 + 3 + 26 + 4; const statusWrite = (text, r, g, b, a = 255) => { if (!text) return false; const width = text.length * 4; if (statusX + width > statusRightLimit) return false; ink(r, g, b, a); write(text, { x: statusX, y: barY, size: 1, font: "matrix" }); statusX += width + 2; return true; }; // Auto-update status (subtle — download/flash progress only) if (autoUpdate.state === "downloading") { const pct = Math.round((osProgress || 0) * 100); statusWrite("os " + pct + "%", 80, 180, 100, 180); } else if (autoUpdate.state === "flashing") { const ap = system.flashPhase ?? 0; const at = ap === 3 ? "verify" : "flash"; statusWrite(at, ap === 3 ? 100 : 255, ap === 3 ? 200 : 160, ap === 3 ? 255 : 60, 200); } else if (autoUpdate.state === "ready") { statusWrite("reboot?", 100, 220, 100, 220); } // Last key pressed — fades from bright to dim over ~60 frames (1s) so // rapid-fire input flashes are visible while idle state stays subtle. // (USB MIDI / UDP MIDI / FPS moved to colored chips on the right side // so the left-of-notepat-label area is dedicated to key-press + auto- // update transient messages.) if (lastKeyPressed) { const age = frame - lastKeyFrame; const fadeA = age < 60 ? Math.max(100, 255 - Math.floor(age * 2.5)) : 100; statusWrite("key:" + lastKeyPressed, 180, 220, 255, fadeA); } // Metronome indicator (pendulum) in status bar — shown when enabled if (metronomeEnabled) { const bpmLabel = metronomeBPM + "b"; const metNeed = bpmLabel.length * 4 + 3 + 14; if (statusX + metNeed <= statusRightLimit) { const metX = statusX; ink(dark ? 160 : 100, dark ? 160 : 100, dark ? 170 : 110); write(bpmLabel, { x: metX, y: barY, size: 1, font: "matrix" }); statusX += bpmLabel.length * 4 + 3; const px = statusX + 5; const pvY = barY + 1; const armLen = 8; const angle = metronomePendulumAngle * 0.45; const bobX = Math.round(px + Math.sin(angle) * armLen); const bobY = Math.round(pvY + Math.cos(angle) * armLen); const db = (metronomeBeatCount % 4) === 0; const fa = Math.floor(metronomeVisualPhase * 255); if (fa > 0) ink(db ? 255 : 180, db ? 100 : 180, db ? 100 : 255, fa); else ink(dark ? 120 : 150, dark ? 130 : 150, dark ? 140 : 160); line(px, pvY, bobX, bobY); box(bobX - 1, bobY - 1, 3, 3, true); statusX += 14; } } // OS update panel (fullscreen, like WiFi panel) if (activeScreen === "os") { const panelY = topBarH; const panelH = h - panelY; ink(dark ? 12 : 240, dark ? 16 : 235, dark ? 24 : 230, 240); box(0, panelY, w, panelH, true); ink(dark ? 40 : 180, dark ? 60 : 160, dark ? 80 : 140); line(0, panelY, w, panelY); const pad = 10; const midY = Math.floor(panelY + panelH * 0.35); const maxVerChars = Math.floor((w - pad * 2) / 6) - 10; // fit label + version // Title — centered ink(dark ? 200 : 40, dark ? 220 : 60, dark ? 200 : 80); const title = "ac/native"; write(title, { x: pad, y: panelY + 8, size: 1, font: "matrix" }); // Connection status if (!wifi?.connected) { ink(200, 80, 80); write("offline", { x: pad, y: panelY + 22, size: 1, font: "font_1" }); ink(dark ? 100 : 120, dark ? 80 : 100, dark ? 80 : 100); write("connect wifi first", { x: pad, y: panelY + 34, size: 1, font: "font_1" }); } else { // Current version ink(dark ? 100 : 100, dark ? 110 : 110, dark ? 100 : 100); write("current", { x: pad, y: panelY + 22, size: 1, font: "font_1" }); ink(dark ? 180 : 40, dark ? 180 : 40, dark ? 180 : 40); const curVer = (osCurrentVersion || "unknown").slice(0, maxVerChars); write(curVer, { x: pad, y: panelY + 34, size: 1, font: "font_1" }); } const stateY = panelY + 50; if (osState === "checking") { ink(200, 200, 80); write("checking...", { x: pad, y: stateY, size: 1, font: "font_1" }); } else if (osState === "up-to-date") { ink(dark ? 100 : 100, dark ? 110 : 110, dark ? 100 : 100); write("available", { x: pad, y: stateY, size: 1, font: "font_1" }); ink(80, 200, 120); write(osRemoteVersion.slice(0, maxVerChars), { x: pad, y: stateY + 12, size: 1, font: "font_1" }); ink(80, 200, 120); write("up to date!", { x: pad, y: stateY + 28, size: 1, font: "font_1" }); } else if (osState === "available") { ink(dark ? 100 : 100, dark ? 110 : 110, dark ? 100 : 100); write("available", { x: pad, y: stateY, size: 1, font: "font_1" }); ink(255, 200, 60); write(osRemoteVersion.slice(0, maxVerChars), { x: pad, y: stateY + 12, size: 1, font: "font_1" }); // Flash target selector const targets = system.flashTargets || []; if (targets.length > 0) { if (flashTargetIdx >= targets.length) flashTargetIdx = 0; const tgt = targets[flashTargetIdx]; const tgtLabel = (tgt?.label || "?") + " (" + (tgt?.device || "?") + ")"; const isBoot = (tgt?.device === system.bootDevice); ink(dark ? 80 : 120, dark ? 100 : 100, dark ? 120 : 80); write("target:", { x: pad, y: stateY + 28, size: 1, font: "font_1" }); ink(isBoot ? 80 : 200, isBoot ? 200 : 200, isBoot ? 255 : 80); write(tgtLabel, { x: pad + 48, y: stateY + 28, size: 1, font: "font_1" }); if (isBoot) { ink(60, 140, 200); write("(current boot)", { x: pad, y: stateY + 40, size: 1, font: "font_1" }); } // Cycle button if multiple targets if (targets.length > 1) { ink(120, 120, 140); const cycX = w - pad - 36; box(cycX, stateY + 26, 32, 14, true); ink(220, 220, 240); write("next", { x: cycX + 4, y: stateY + 29, size: 1, font: "font_1" }); globalThis.__osCycleBtn = { x: cycX, y: stateY + 26, w: 32, h: 14 }; } else { globalThis.__osCycleBtn = null; } } // Update button — centered, proportional const btnW = Math.min(120, w - pad * 4); const btnX = Math.floor((w - btnW) / 2); const btnY = stateY + (targets.length > 0 ? 56 : 34); ink(60, 180, 100); box(btnX, btnY, btnW, 18, true); ink(dark ? 220 : 240, dark ? 255 : 255, dark ? 220 : 240); write("update now", { x: btnX + Math.floor((btnW - 60) / 2), y: btnY + 4, size: 1, font: "font_1" }); globalThis.__osUpdateBtn = { x: btnX, y: btnY, w: btnW, h: 18 }; } else if (osState === "downloading") { ink(dark ? 120 : 80, dark ? 140 : 100, dark ? 120 : 80); write("downloading...", { x: pad, y: stateY, size: 1, font: "font_1" }); // Progress bar — full width with padding const barX = pad, barW2 = w - pad * 2, barH2 = 8, barY2 = stateY + 16; ink(dark ? 30 : 200, dark ? 40 : 190, dark ? 50 : 180); box(barX, barY2, barW2, barH2, true); ink(60, 180, 100); box(barX, barY2, Math.round(barW2 * (osProgress || 0)), barH2, true); ink(dark ? 160 : 80); write(Math.round((osProgress || 0) * 100) + "%", { x: pad, y: barY2 + 12, size: 1, font: "font_1" }); } else if (osState === "flashing") { const phase = system.flashPhase ?? 0; const phaseText = phase === 1 ? "writing EFI..." : phase === 2 ? "syncing to disk..." : phase === 3 ? "verifying..." : phase === 4 ? "done" : "preparing..."; const phaseColor = phase === 3 ? [100, 200, 255] : [255, 160, 60]; ink(...phaseColor); write(phaseText, { x: pad, y: stateY, size: 1, font: "font_1" }); ink(dark ? 100 : 80); const flashDev = globalThis.__osFlashDevice || system.bootDevice || "?"; write("→ " + flashDev, { x: pad, y: stateY + 14, size: 1, font: "font_1" }); ink(dark ? 140 : 80); write("do not power off", { x: pad, y: stateY + 26, size: 1, font: "font_1" }); } else if (osState === "rebooting") { const vb = system.flashVerifiedBytes ?? 0; const mb = (vb / 1048576).toFixed(1); ink(80, 255, 120); write(`verified ${mb}MB`, { x: pad, y: stateY, size: 1, font: "font_1" }); ink(200, 100, 60); write("rebooting...", { x: pad, y: stateY + 14, size: 1, font: "font_1" }); } else if (osState === "error") { ink(220, 80, 80); const errTxt = ("error: " + osError).slice(0, Math.floor((w - pad * 2) / 6)); write(errTxt, { x: pad, y: stateY, size: 1, font: "font_1" }); ink(dark ? 120 : 80); write("tap os to retry", { x: pad, y: stateY + 14, size: 1, font: "font_1" }); } // Dismiss hint — bottom ink(dark ? 60 : 160, dark ? 80 : 150, dark ? 60 : 140); write("tap os to close", { x: pad, y: h - 12, size: 1, font: "font_1" }); } // Center status bar: note count is enough, settings go below const centerX = Math.floor(w / 2); // TTS + WebSocket connect on WiFi connect transition (with 10s cooldown) if (wifi?.connected && !wifiWasConnected && (frame - wifiConnectFrame) > 600) { wifiConnectFrame = frame; // WiFi connect chord: perfect fifth (G5 + D6) sound?.synth({ type: "sine", tone: 784, duration: 0.18, volume: 0.22, attack: 0.005, decay: 0.15 }); sound?.synth({ type: "sine", tone: 1175, duration: 0.18, volume: 0.16, attack: 0.005, decay: 0.15 }); autoConnectTry = 0; wsStatus = "connecting"; wsConnectGrace = 180; // ~3s grace before declaring error system.ws?.connect("wss://chat-system.aesthetic.computer/"); // Connect raw UDP for fairy co-presence system.udp?.connect("session-server.aesthetic.computer", 10010); udpMidiNextHeartbeatFrame = frame + 30; if (system?.writeFile && savedCreds.length > 0) { system.writeFile(CREDS_PATH, JSON.stringify(savedCreds)); } // Kick off clock sync fetch (only if fetch slot is free) clockSynced = false; clockSyncFrame = 0; if (!system.fetchPending) { system.fetch?.("https://aesthetic.computer/api/clock"); } // Kick off update check shortly after connect (background ping + auto-update path). scheduleAutoUpdateCheck(300); // Start SSH daemon for remote access if (!system.sshStarted) system.startSSH?.(); } wifiWasConnected = !!wifi?.connected; if (!wifi?.connected && autoUpdate.state === "checking") { autoUpdate.fetchPending = false; autoUpdate.state = "idle"; } // Background update check schedule while connected. if (wifi?.connected && autoUpdate.state === "checking" && !autoUpdate.fetchPending && !osFetchPending && frame >= autoUpdate.nextCheckFrame && !system.fetchPending) { autoUpdate.fetchPending = true; system.fetch?.(OS_VERSION_URL); } // HTTP error routing for single fetch slot. if (system.fetchError) { const err = (system.fetchError || "request failed").trim().slice(0, 120); if (osFetchPending) { osFetchPending = false; osError = err; osState = "error"; } else if (autoUpdate.fetchPending) { autoUpdate.fetchPending = false; autoUpdate.lastError = err; autoUpdate.state = "checking"; autoUpdate.nextCheckFrame = frame + OS_AUTO_RETRY_FRAMES; } } // Poll for fetch result — priority: manual OS panel > auto-update > clock sync if (system.fetchResult) { const fetchText = system.fetchResult.trim(); if (osFetchPending) { // Manual OS panel version check osFetchPending = false; const ver = fetchText; if (ver && ver.length > 0 && ver.length < 128) { const newer = isRemoteVersionNewer(ver, osCurrentVersion); osRemoteVersion = ver; autoUpdate.availableVersion = newer ? ver : ""; osState = newer ? "available" : "up-to-date"; console.log(`[os] manual-check local=${osCurrentVersion} remote=${ver} newer=${newer}`); if (newer) notifyUpdateAvailable(sound, ver); } else { osError = "version check failed"; osState = "error"; } } else if (autoUpdate.fetchPending) { // Background auto-update version check autoUpdate.fetchPending = false; const ver = fetchText; const newer = isRemoteVersionNewer(ver, osCurrentVersion); console.log(`[os] auto-check local=${osCurrentVersion} remote=${ver} newer=${newer}`); if (ver && ver.length > 0 && ver.length < 128 && newer) { autoUpdate.availableVersion = ver; osRemoteVersion = ver; notifyUpdateAvailable(sound, ver); // Don't auto-download — user must manually trigger via OS panel autoUpdate.state = "idle"; } else { autoUpdate.availableVersion = ""; autoUpdate.state = "checking"; autoUpdate.nextCheckFrame = frame + OS_AUTO_CHECK_INTERVAL_FRAMES; } } else { // Clock sync result const t1 = Date.now(); try { const serverTime = new Date(fetchText).getTime(); if (!isNaN(serverTime)) { const newOffset = serverTime - t1; clockOffset += (newOffset - clockOffset) * 0.5; clockSynced = true; } } catch (e) { /* ignore parse errors */ } } } // Periodic re-sync every ~10 min (at 60fps: 36000 frames) clockSyncFrame++; if (wifi?.connected && clockSyncFrame % 36000 === 0 && !osFetchPending && !autoUpdate.fetchPending && !system.fetchPending) { system.fetch?.("https://aesthetic.computer/api/clock"); } // NOTE: there is intentionally NO background auto-download / auto-flash / // auto-reboot. The background path ONLY checks the version and speaks a // "system update available" notice (notifyUpdateAvailable, above). All // downloading + flashing happens solely through the manual OS panel, where // the user explicitly confirms. (The old silent download→flash→reboot state // machine was removed — a self-flashing reboot is too dangerous to run // unattended, especially on a tight ESP.) // OS update panel state machine (manual, only when panel open) if (activeScreen === "os") { if (osState === "checking" && !wifi?.connected) { osState = "error"; osError = "no wifi connection"; } else if (osState === "checking" && !osFetchPending) { // Cancel any in-flight auto-update fetch so we can use the fetch slot if (autoUpdate.fetchPending) { system.fetchCancel?.(); autoUpdate.fetchPending = false; autoUpdate.state = "checking"; autoUpdate.nextCheckFrame = frame + OS_AUTO_RETRY_FRAMES; } // Wait for C-side fetch slot to be free before issuing if (!system.fetchPending) { osFetchPending = true; osCheckFrame = frame; osError = ""; system.fetch?.(OS_VERSION_URL); } } // Timeout: if checking for > 10s (600 frames), give up if (osState === "checking" && osFetchPending && frame - osCheckFrame > 600) { system.fetchCancel?.(); osFetchPending = false; osState = "error"; osError = "request timed out"; } if (osState === "downloading") { osProgress = system.fetchBinaryProgress ?? osProgress; if (system.fetchBinaryDone) { if (system.fetchBinaryOk) { const dev = globalThis.__osFlashDevice; console.log(`[os] manual download complete, flashing to ${dev || "auto"}`); osState = "flashing"; if (dev) { system.flashUpdate?.("/tmp/vmlinuz.new", dev); } else { system.flashUpdate?.("/tmp/vmlinuz.new"); } } else { console.log("[os] manual download FAILED"); osError = "download failed"; osState = "error"; } } } if (osState === "flashing") { if (system.flashDone) { if (system.flashOk) { console.log("[os] manual flash complete! ready to reboot"); osState = "rebooting"; globalThis.__osRebootFrame = frame + 180; // 3s at 60fps } else { console.log("[os] manual flash FAILED"); osError = "flash failed"; osState = "error"; } } } if (osState === "rebooting" && globalThis.__osRebootFrame && frame >= globalThis.__osRebootFrame) { console.log("[os] rebooting now!"); system.reboot?.(); } } // Track WS connection status + auto-reconnect when server drops if (wsConnectGrace > 0) wsConnectGrace--; if (wsReconnectTimer > 0) wsReconnectTimer--; if (system.ws?.connecting) { /* don't overwrite wsStatus here — preserve "reconnecting" */ wsConnectGrace = 0; } else if (system.ws?.connected && wsStatus !== "connected") { const wasFirstConnect = (wsStatus !== "reconnecting" && wsStatus !== "connected"); wsStatus = "connected"; wsConnectGrace = 0; wsReconnectAttempts = 0; // clean connect resets the backoff ramp // Chat connected ding — only on very first connect, never on reconnects if (wasFirstConnect && !globalThis.__wsEverConnected) { globalThis.__wsEverConnected = true; sound?.synth({ type: "sine", tone: 1047, duration: 0.1, volume: 0.18, attack: 0.003, decay: 0.08 }); sound?.synth({ type: "sine", tone: 1319, duration: 0.1, volume: 0.14, attack: 0.02, decay: 0.08 }); } } else if (!system.ws?.connected && !system.ws?.connecting && wsConnectGrace === 0) { if (wsStatus === "connecting") wsStatus = "error"; // Auto-reconnect with EXPONENTIAL BACKOFF. The chat server sends ~500 // messages of history then closes the socket, so a tight 2s reconnect // loop hammered it — and each reconnect re-parsed a ~97KB JSON backlog on // the main thread, which is a prime suspect for the 65ms frame hitches. // Backoff: 5s, 10s, 20s, capped at 30s (300..1800 frames at 60fps). Chat // is non-essential to notepat's core, so a slow reconnect is fine. if (wifi?.connected && wsReconnectTimer === 0 && wsStatus !== "connecting" && wsStatus !== "reconnecting") { const backoffFrames = Math.min(1800, 300 * Math.pow(2, Math.min(wsReconnectAttempts, 3))); wsReconnectAttempts++; wsReconnectTimer = backoffFrames; wsStatus = "reconnecting"; wsConnectGrace = backoffFrames + 60; system.ws?.connect("wss://chat-system.aesthetic.computer/"); } } // Process incoming WebSocket chat messages (real-time) const wsMsgs = system.ws?.messages; if (wsMsgs?.length) { for (const raw of wsMsgs) { try { const msg = JSON.parse(raw); // content may be a string (needs parsing) or already an object const parseContent = (c) => (typeof c === "string" ? JSON.parse(c) : c); if (msg.type === "connected") { wsStatus = "connected"; // lock in even if server closes right after // BACKLOG COST GUARD: the server's "connected" frame carries the // entire ~500-message / ~97KB history. JSON.parse-ing that on the // main thread *every* reconnect caused 65ms frame hitches. We only // need the LATEST message for display (chat TTS is muted), and live // "message" frames deliver new ones afterward — so parse the heavy // backlog only ONCE (first connect). On reconnects, skip it // entirely: the existing acMsg stays put until a fresh live message // arrives. This keeps reconnect cheap and the parse off the hot path. if (!wsBacklogProcessed) { wsBacklogProcessed = true; const content = parseContent(msg.content); const msgs = content?.messages || []; console.log("ws connected: " + msgs.length + " messages"); const last = msgs.slice(-1)[0]; if (last) console.log("last msg: from=" + last.from + " text=" + (last.text || "").slice(0, 40)); if (last?.from && last?.text) { const msgKey = last.from + ":" + last.text; acMsg = { from: last.from, text: last.text }; // Only TTS on first connect, not on reconnects with same last msg if (!chatMuted && msgKey !== lastSpokenMsgKey) { lastSpokenMsgKey = msgKey; sound?.speak(last.from + ": " + last.text); } } } else { console.log("ws reconnected (backlog parse skipped)"); } } else if (msg.type === "message") { const m = parseContent(msg.content); if (m?.from && m?.text) { acMsg = { from: m.from, text: m.text }; if (!chatMuted) sound?.speak(m.from + ": " + m.text); } } else if (msg.from && msg.text) { acMsg = { from: msg.from, text: msg.text }; if (!chatMuted) sound?.speak(msg.from + ": " + msg.text); } } catch (err) { console.log("ws parse error: " + err); } } } // Auto-connect: scan, pick strongest known network, timeout after 5s // Disabled when WiFi panel is open (user is manually picking) autoConnectFrame++; autoConnectBlink = (autoConnectBlink + 1) % 60; const autoConnectEnabled = activeScreen !== "wifi"; // All known credentials const knownCreds = [ { ssid: AC_SSID, pass: AC_PASS }, ...savedCreds.filter((c) => c.ssid !== AC_SSID), ]; const knownSSIDs = new Set(knownCreds.map((c) => c.ssid)); const isConnecting = wifi && !wifi.connected && (wifi.state === 3 /* CONNECTING */ || wifi.state === 4); const isIdle = wifi && !wifi.connected && wifi.state !== 3 && wifi.state !== 4; // Timeout: if connecting for > 180 frames (~3s), disconnect and retry (silent) if (autoConnectEnabled && isConnecting && frame - connectStartFrame > 180) { wifi.disconnect?.(); autoConnectFrame = -30; // brief delay before next attempt } if (autoConnectEnabled && isIdle) { // Trigger scan every ~30s (1800 frames) — iw scan causes 65ms+ frame // drops so keep it infrequent. Immediate on first frame only. if (autoConnectFrame <= 1 || autoConnectFrame % 1800 === 0) { wifi.scan?.(); } // 60 frames (~1s) after scan: pick strongest known network from scan results if (autoConnectFrame % 120 === 60) { const nets = wifi.networks || []; const matches = nets .filter((n) => n.ssid && knownSSIDs.has(n.ssid)) .sort((a, b) => b.signal - a.signal); // strongest first autoConnectTry++; if (matches.length > 0) { const best = matches[0]; const cred = knownCreds.find((c) => c.ssid === best.ssid); wifi.connect(cred.ssid, cred.pass); connectStartFrame = frame; } // No known networks in scan — just wait for next scan cycle (no blind attempts) } } // Right section: battery | time | vol let rx = w - 2; // right edge cursor (builds right to left) // Battery icon + percentage const bat = system?.battery; const batPct = bat?.percent ?? -1; // Battery sound: 5% increments normally, every 1% below 10% // Lower battery = higher pitch, louder, longer, more voices (more urgent) { const batThreshold = batPct <= 10 ? 1 : 5; if (batPct >= 0 && lastBatPercent >= 0 && Math.abs(batPct - lastBatPercent) >= batThreshold) { const charging = bat?.charging; if (charging) { // Charging: gentle ascending major triad sound?.synth({ type: "sine", tone: 523, duration: 0.15, volume: 0.12, attack: 0.01, decay: 0.12 }); sound?.synth({ type: "sine", tone: 659, duration: 0.15, volume: 0.10, attack: 0.03, decay: 0.12 }); sound?.synth({ type: "sine", tone: 784, duration: 0.15, volume: 0.08, attack: 0.05, decay: 0.10 }); } else { // Draining: pitch rises as battery drops (100%→220Hz, 0%→1760Hz) const urgency = 1 - batPct / 100; // 0 at full, 1 at empty const base = 220 + urgency * 1540; const vol = 0.08 + urgency * 0.32; // 0.08 at full, 0.40 at empty const dur = 0.08 + urgency * 0.30; // short at full, long at empty // Root + minor third + tritone (increasingly dissonant as battery drops) sound?.synth({ type: "sine", tone: base, duration: dur, volume: vol, attack: 0.005, decay: dur * 0.8 }); sound?.synth({ type: "triangle", tone: base * 1.2, duration: dur * 0.9, volume: vol * 0.6, attack: 0.01, decay: dur * 0.7 }); sound?.synth({ type: "sine", tone: base * 1.414, duration: dur * 0.7, volume: vol * 0.4, attack: 0.02, decay: dur * 0.5 }); // Below 20%: add a 4th voice — high square pulse if (batPct < 20) { sound?.synth({ type: "square", tone: base * 2, duration: dur * 0.5, volume: vol * 0.25, attack: 0.001, decay: dur * 0.4 }); } // Below 10%: add a 5th voice — sub-bass throb if (batPct < 10) { sound?.synth({ type: "sawtooth", tone: base * 0.5, duration: dur * 1.2, volume: vol * 0.5, attack: 0.005, decay: dur }); } // At 5% or below: alarm cluster if (batPct <= 5) { sound?.synth({ type: "square", tone: base * 3, duration: 0.04, volume: 0.3, attack: 0.001, decay: 0.03 }); sound?.synth({ type: "sine", tone: base * 2.5, duration: 0.06, volume: 0.2, attack: 0.001, decay: 0.05 }); } } lastBatPercent = batPct; } else if (batPct >= 0 && lastBatPercent < 0) { lastBatPercent = batPct; // initial read, no sound } } if (batPct >= 0) { const batW = 12, batH = 6; rx -= batW + 2; const batX = rx; // Below 10%: rapid blink (faster as battery drops). Hide icon on blink-off frames. const batCritical = batPct <= 10 && !bat?.charging; const blinkRate = batCritical ? Math.max(4, batPct * 2) : 0; // frames per blink cycle const batVisible = !batCritical || (frame % blinkRate < blinkRate / 2); if (batVisible) { ink(dark ? 90 : 150, dark ? 90 : 150, dark ? 90 : 150); box(batX, barY, batW, batH, "outline"); box(batX + batW, barY + 2, 2, 2, true); const fillW = Math.max(0, Math.floor(batPct * (batW - 2) / 100)); if (batPct <= 10) ink(255, 0, 0); else if (batPct <= 20) ink(220, 30, 30); else if (batPct <= 50) ink(200, 160, 0); else ink(50, 160, 50); if (fillW > 0) box(batX + 1, barY + 1, fillW, batH - 2, true); // Charging indicator: yellow bolt inside battery if (bat?.charging) { ink(255, 220, 40); box(batX + 4, barY + 1, 2, 2, true); box(batX + 3, barY + 3, 2, 2, true); box(batX + 5, barY + 2, 1, 1, true); } } const pctStr = (bat?.charging ? "+" : "") + batPct + "%"; rx -= pctStr.length * CH + 2; if (batCritical && !batVisible) { ink(255, 0, 0); // red flash on text during blink-off } else { ink(FG_DIM, FG_DIM, FG_DIM); } write(pctStr, { x: rx, y: barY, size: 1 }); rx -= 4; // separator space } // Time (LA) — uses syncedNow() for accuracy; shows green dot when synced { const now = new Date(syncedNow()); const laMs = now.getTime() - getLAOffset() * 3600000; const laDate = new Date(laMs); const hh = laDate.getUTCHours(); const mm = laDate.getUTCMinutes(); const ss = laDate.getUTCSeconds(); const ampm = hh >= 12 ? "p" : "a"; const h12 = hh % 12 || 12; const timeStr = h12 + ":" + (mm < 10 ? "0" : "") + mm + ":" + (ss < 10 ? "0" : "") + ss + ampm; rx -= timeStr.length * CH; // Draw clock with green colons when synced let cx = rx; for (let ci = 0; ci < timeStr.length; ci++) { const ch = timeStr[ci]; if (ch === ":" && clockSynced) { ink(60, 220, 80); } else { ink(FG_DIM, FG_DIM, FG_DIM); } write(ch, { x: cx, y: barY, size: 1 }); cx += CH; } rx -= 4; } // Volume bar — taller (6 px) with high-contrast BG track so you can // see the full drag range at a glance, filled portion shows current. const sysVol = sound?.speaker?.systemVolume ?? 100; { const volW = 20, volH = 6; const volBarY = barY - 1; rx -= volW; const volBarX = rx; // Max-range track (dark cool-gray in dark mode, cool-light in light) ink(dark ? 55 : 205, dark ? 55 : 205, dark ? 70 : 215, 255); box(rx, volBarY, volW, volH, true); const fillV = Math.floor(sysVol * volW / 100); if (fillV > 0) { ink(dark ? 180 : 60, dark ? 200 : 80, dark ? 220 : 140, 255); box(rx, volBarY, fillV, volH, true); } // 1-px border frame ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170, 200); box(rx, volBarY, volW, 1, true); box(rx, volBarY + volH - 1, volW, 1, true); rx -= 2; ink(FG_MUTED, FG_MUTED, FG_MUTED); rx -= 3 * CH; write("vol", { x: rx, y: barY, size: 1 }); globalThis.__volBar = { x: rx, w: volBarX + volW - rx, barX: volBarX, barW: volW }; } // Brightness bar — same prominence as volume const sysBrt = system?.brightness ?? -1; if (sysBrt >= 0) { rx -= 4; const brtW = 16, brtH = 6; const brtBarY = barY - 1; rx -= brtW; const brtBarX = rx; ink(dark ? 55 : 205, dark ? 55 : 205, dark ? 70 : 215, 255); box(rx, brtBarY, brtW, brtH, true); const fillB = Math.floor(sysBrt * brtW / 100); if (fillB > 0) { ink(dark ? 220 : 120, dark ? 200 : 90, dark ? 100 : 40, 255); box(rx, brtBarY, fillB, brtH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170, 200); box(rx, brtBarY, brtW, 1, true); box(rx, brtBarY + brtH - 1, brtW, 1, true); rx -= 2; ink(FG_MUTED, FG_MUTED, FG_MUTED); rx -= 3 * CH; write("brt", { x: rx, y: barY, size: 1 }); globalThis.__brtBar = { x: rx, w: brtBarX + brtW - rx, barX: brtBarX, barW: brtW }; } // Status chips — USB MIDI / UDP MIDI / FPS grouped as small colored // strips to the left of the brightness bar. Each chip has a tinted // background + short monospace label so their states read at a glance // without mixing into the key-press / auto-update message stream on // the left. Layout (right → left, starting from current `rx`): // // … [brt] [vol] [FPS:60] [UDP·MIDI] [USB·MIDI] [time] [bat] // // Chip helper — draws a filled rectangle with `fg` text centered in // it, advances `rx` leftward. `bg`/`fg` are 4-tuples {r,g,b,a}. const chipH = topBarH - 4; const chipY = 2; const chipPad = 3; const drawStatusChip = (text, bg, fg) => { const chipW = text.length * CH + chipPad * 2; rx -= 3; // gap between chips rx -= chipW; ink(bg[0], bg[1], bg[2], bg[3]); box(rx, chipY, chipW, chipH, true); // Thin bottom accent for tactility ink(Math.max(0, bg[0] - 40), Math.max(0, bg[1] - 40), Math.max(0, bg[2] - 40), bg[3]); box(rx, chipY + chipH - 1, chipW, 1, true); ink(fg[0], fg[1], fg[2], fg[3]); write(text, { x: rx + chipPad, y: barY, size: 1, font: "matrix" }); }; // FPS chip — color graded green (≥55), amber (≥30), red (<30) if (fpsDisplay > 0) { const fpsStr = "fps" + fpsDisplay; const fpsBg = fpsDisplay >= 55 ? [30, 80, 50, 200] : fpsDisplay >= 30 ? [100, 70, 25, 200] : [110, 30, 30, 220]; const fpsFg = fpsDisplay >= 55 ? [160, 255, 190, 255] : fpsDisplay >= 30 ? [255, 220, 140, 255] : [255, 180, 180, 255]; drawStatusChip(fpsStr, fpsBg, fpsFg); } // UDP MIDI chip — state colors // off: dim gray (relay disabled in config.json) // connecting: amber // connected: dim green // sending: bright green (pulses with recency of last sent note) { const label = udpMidiBroadcast ? (!system?.udp?.connected ? "udp..." : "udp ok") : "udp off"; let bg, fg; if (!udpMidiBroadcast) { bg = [40, 40, 50, 200]; fg = [140, 140, 150, 220]; } else if (!system?.udp?.connected) { bg = [100, 70, 25, 210]; fg = [255, 220, 140, 255]; } else { const recency = udpMidiSendRecency(); bg = [Math.round(30 + recency * 70), Math.round(80 + recency * 90), Math.round(50 + recency * 40), 210]; fg = [Math.round(160 + recency * 60), Math.round(255), Math.round(190 + recency * 40), 255]; } drawStatusChip(label, bg, fg); } // USB MIDI chip { const label = usbMidiStatus?.active ? "usb on" : (usbMidiStatus?.enabled ? "usb..." : "usb off"); let bg, fg; if (usbMidiStatus?.active) { bg = [30, 80, 50, 210]; fg = [160, 255, 190, 255]; } else if (usbMidiStatus?.enabled) { bg = [100, 70, 25, 210]; fg = [255, 220, 140, 255]; } else { bg = [40, 40, 50, 200]; fg = [140, 140, 150, 220]; } drawStatusChip(label, bg, fg); } // === NOTEPAT SCREEN: pads, waveform, echo slider === if (activeScreen === "notepat") { // === SPLIT GRID: 4x3 left (bottom-left) + 4x3 right (bottom-right) === const gap = 0; // marginless — buttons touch for rollover clicking const cols = 4, rows = 3; // Reserve ~8px on each edge for the per-side master volume sliders that // sit to the left of the left grid and right of the right grid. const masterSliderW = 7; const margin = masterSliderW + 2; // Small buttons: ~30% of screen height, anchored to bottom corners const maxGridH = Math.floor(h * 0.30); const btnH = Math.floor(maxGridH / rows); const maxBtnW = Math.floor((w / 2 - margin * 2) / cols); const btnW = Math.min(maxBtnW, btnH * 2); const gridW = cols * btnW; const gridH = rows * btnH; const gridTop = h - gridH - margin - 8; // anchor near bottom, leave room for echo bar const leftX = margin; const rightX = w - gridW - margin; // Scrolling record-needle strip with continuous-drift playback cursor. // // waveViewOffsetSec drives the cursor's position relative to live audio: // = 0 → cursor at live edge, past on LEFT, right empty; // wave drifts LEFT as new audio arrives // > 0, growing → cursor retreats into the past; right half fills in // with samples captured AFTER the cursor; wave drifts // RIGHT (backwards-replay visual) // > 0, shrinking→ cursor catches back up to "now"; wave drifts LEFT // faster than normal until offset hits 0 // // waveViewOffsetSec is advanced/retreated in sim() below based on // spaceHeld. The C drawStrip reads the offset and renders accordingly // in a single call (no JS peak loop). const recordStripH = 22; const recordStripSeconds = 4; const recordStripTop = Math.max(topBarH + 1, gridTop - recordStripH - 2); if (sound?.speaker?.drawStrip) { const rsX = margin; const rsW = w - margin * 2; sound.speaker.drawStrip(rsX, recordStripTop, rsW, recordStripH, recordStripSeconds, 0.5, waveViewOffsetSec); // Overlay: needle blinks GREEN when a note is actively being played. // Drawn at the EXACT same X as the C-rendered red needle — the color // change is the only difference, no shake/offset, so it reads as // "same line, different state" instead of two separate bars. The // underlying red needle shows through during the dim half of the // blink cycle, giving a subtle red↔green pulse without any double // vision. const framesSincePress = frame - lastKeyFrame; const noteActive = activeCount > 0 || framesSincePress < 18; if (noteActive) { const needleX = rsX + Math.floor(rsW * 0.5); // Blink intensity pulses at ~15 Hz (every 4 frames at 60 fps). const blink = (frame & 3) < 2 ? 255 : 140; const ageFade = framesSincePress < 6 ? 255 : Math.max(120, 255 - (framesSincePress - 6) * 10); const a = activeCount > 0 ? 255 : ageFade; ink(90, blink, 130, a); line(needleX, recordStripTop, needleX, recordStripTop + recordStripH); } } // Waveform visualizer bars only in lanes above pad grids (not full-screen). // Shortened so the record-needle strip fits above the pads without // clipping the existing per-lane visualizer. const wf = sound?.speaker?.waveforms?.left; if (wf && activeCount > 0) { const wfTop = topBarH + 1; const wfBottom = Math.max(wfTop + 6, recordStripTop - 2); const wfH = wfBottom - wfTop; if (wfH > 2) { const wfBottomY = wfTop + wfH; const amp = Math.floor(wfH * 0.9); const barsPerSide = 24; const laneW = gridW; const barW = Math.max(1, Math.floor(laneW / barsPerSide)); const vGap = 1; const drawLane = (x0, phase) => { // Single background tint per lane (not per-bar — avoids alpha overdraw) ink(dark ? 30 : 220, dark ? 10 : 200, dark ? 50 : 180, 36); box(x0, wfTop, laneW, wfH, true); for (let i = 0; i < barsPerSide; i++) { const t = (i / barsPerSide) * 0.5 + phase; const si = Math.min((wf.length || 1) - 1, Math.floor(t * (wf.length || 1))); const sample = Math.abs(wf[si] || 0); const barH = Math.max(2, Math.round(sample * amp)); const barTop = wfBottomY - barH; const bx = x0 + i * barW; const bw = Math.max(1, Math.min(barW - vGap, x0 + laneW - bx)); if (bw <= 0 || bx >= x0 + laneW) break; // Opaque bar fill (no alpha — fast path) ink(dark ? 0 : 200, dark ? 200 : 40, dark ? 255 : 0); box(bx, barTop, bw, barH, true); ink(dark ? 255 : 255, dark ? 60 : 0, dark ? 180 : 100); box(bx, barTop, bw, 2, true); } }; drawLane(leftX, 0.0); drawLane(rightX, 0.5); } } // === BANDMATE CHARACTER (per-wave-type) === // Each instrument tab gets its own pixel-art bandmate drawn in the // empty center between the left + right grids. Each one has expression // frames keyed to note activity, eyes that track the cursor (so they // address the user), and a mouth/instrument origin for the floating // music-note particles. See CHARACTER_SPRITES near the top of the file // for the per-character sprite tables and palettes. const betweenX0 = leftX + gridW; const betweenX1 = rightX; // When a GM program is active its family character stands in for the AC // wavetype bandmate, so the center figure matches the sounding voice. const bandmateKey = gmProgram !== null ? gmFamilyKey(gmProgram) : wave; const bandmateOrigin = drawBandmate(bandmateKey, ink, box, { dark, frame, activeCount, lastKeyFrame, hoverX, hoverY, centerX: (betweenX0 + betweenX1) / 2, topY: topBarH, bottomY: recordStripTop, }); // === MUSIC-NOTE PARTICLES === // Scan trail[] for freshly-pressed notes (brightness > 0.95). For each // one we haven't already emitted for at this trigger, spawn a music // note particle from the bandmate's mouth/instrument origin in the // note's color. Old emit tokens for keys no longer in trail are // cleared so a re-press fires a new emission. if (bandmateOrigin) { for (const key in trail) { const tr = trail[key]; if (!tr) continue; // Trail brightness decays *= 0.92 per frame, so a fresh trigger // lands near 1.0 and is below 0.9 within ~2 frames. Emit once per // trigger by gating on >0.9 brightness + a min-age cooldown so a // sustained press doesn't spam particles, but a re-press (which // resets brightness to 1.0) does. if (tr.brightness > 0.9) { const age = frame - (lastTrailEmit[key] || -999); if (age > 6) { const c = NOTE_COLORS[tr.note] || [180, 180, 180]; emitMusicNote(bandmateOrigin.mouthX, bandmateOrigin.mouthY, c); lastTrailEmit[key] = frame; } } } // Drop stale tokens so a re-press always re-emits. for (const key in lastTrailEmit) { if (!trail[key]) delete lastTrailEmit[key]; } } updateMusicNotes(); drawMusicNotes(ink, box); // Expose grid layout for touch hit-testing in act() globalThis.__gridInfo = { leftX, rightX, gridTop, btnW, btnH, gap }; // Chord-tone HUD highlight: gather every MIDI note that's currently // SOUNDING as part of a held chord, so drawGrid can light up the whole // chord shape (root + extensions) on the board — not just the physically // pressed key. `chordRootMidi` holds the pressed roots so we can tint the // root differently from the +3/+4/+2/+7 extension tones. Tones whose MIDI // number doesn't land on any on-board pad are simply not lit. const chordToneMidi = new Set(); // all chord tones (root + extensions) const chordRootMidi = new Set(); // just the pressed roots for (const k of Object.keys(sounds)) { const s = sounds[k]; if (!s) continue; const ivls = s.chordIvls; if (!ivls || ivls.length < 2) continue; // single notes use the normal path const root = noteToMidiNumber(s.note, s.octave); chordRootMidi.add(root); for (const iv of ivls) chordToneMidi.add(Math.max(0, Math.min(127, root + iv))); } function drawGrid(grid, startX, octOffset, side) { // Active kit for this side: "off" = melodic notes, "perc" = drums, // "war" = physically-modeled weapons. Colors, labels and notation // glyphs all come from the kit tables. const kit = side === "left" ? kitLeft : kitRight; const isKit = kit !== "off"; const kitNames = kitNamesFor(kit); const kitLabels = kitLabelsFor(kit); const kitColors = kitColorsFor(kit); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const noteName = grid[r][c]; const [letter, off] = parseNote(noteName); // octOffset is the per-side offset (leftOctaveOffset or rightOctaveOffset) // off from parseNote: 0 for left grid notes, 1 for right grid "+" notes // Use octOffset directly as the side's octave shift const noteOctave = octave + octOffset; const key = NOTE_TO_KEY[noteName]; const directActive = key && sounds[key] !== undefined; // Chord-tone lighting: this pad lights up if its MIDI note is part of // a held chord, even when its own key isn't physically pressed. Drum // sides never participate (chords are melodic-only). const padMidi = isKit ? -1 : noteToMidiNumber(letter, noteOctave); const chordTone = !directActive && padMidi >= 0 && chordToneMidi.has(padMidi); const chordIsRoot = chordTone && chordRootMidi.has(padMidi); const isActive = directActive || chordTone; const trailInfo = key && trail[key]; const sharp = letter.includes("#"); const drumActive = isKit && kitNames && !!kitNames[letter]; const nc = drumActive ? (kitColors[letter] || noteColor(letter)) : noteColor(letter); const x = startX + c * (btnW + gap); const y = gridTop + r * (btnH + gap); const isHovered = hoverX >= x && hoverX < x + btnW && hoverY >= y && hoverY < y + btnH; if (isActive) { if (chordTone && !chordIsRoot) { // Chord EXTENSION (the +2/+3/+4/+7 tones): dimmer note-color // fill so the chord shape is visible but the pressed root still // reads as the brightest pad. ink(Math.floor(nc[0] * 0.7), Math.floor(nc[1] * 0.7), Math.floor(nc[2] * 0.7)); } else { // Directly-pressed pad or chord root: full note color. ink(nc[0], nc[1], nc[2]); } box(x, y, btnW, btnH, true); ink(255, 255, 255); } else if (trailInfo && trailInfo.brightness > 0.05) { const b = trailInfo.brightness; if (dark) { ink(Math.floor(nc[0] * b * 0.4), Math.floor(nc[1] * b * 0.4), Math.floor(nc[2] * b * 0.4)); } else { ink(Math.floor(255 - (255 - nc[0]) * b * 0.4), Math.floor(255 - (255 - nc[1]) * b * 0.4), Math.floor(255 - (255 - nc[2]) * b * 0.4)); } box(x, y, btnW, btnH, true); ink(dark ? 200 : 40, dark ? 200 : 40, dark ? 200 : 40); } else { // Idle: note color — sharps are darker (black keys) const sharpDim = sharp ? 0.15 : 0.35; const sharpDimL = sharp ? 0.08 : 0.3; if (dark) { ink(Math.floor(nc[0] * sharpDim) + (sharp ? 8 : 20), Math.floor(nc[1] * sharpDim) + (sharp ? 8 : 20), Math.floor(nc[2] * sharpDim) + (sharp ? 10 : 22)); } else { ink(Math.floor(255 - (255 - nc[0]) * sharpDimL) - (sharp ? 60 : 0), Math.floor(255 - (255 - nc[1]) * sharpDimL) - (sharp ? 60 : 0), Math.floor(255 - (255 - nc[2]) * sharpDimL) - (sharp ? 60 : 0)); } box(x, y, btnW, btnH, true); const outDim = sharp ? 0.25 : 0.5; if (dark) { ink(Math.floor(nc[0] * outDim) + (sharp ? 15 : 30), Math.floor(nc[1] * outDim) + (sharp ? 15 : 30), Math.floor(nc[2] * outDim) + (sharp ? 18 : 33)); } else { ink(Math.floor(255 - (255 - nc[0]) * 0.45) - (sharp ? 40 : 0), Math.floor(255 - (255 - nc[1]) * 0.45) - (sharp ? 40 : 0), Math.floor(255 - (255 - nc[2]) * 0.45) - (sharp ? 40 : 0)); } box(x, y, btnW, btnH, "outline"); if (isHovered) { ink(255, 255, 255, 30); box(x, y, btnW, btnH, true); } // Per-pad hit flash — bright fill that fades over ~12 frames. // Indexed by side+letter so left/right pads light up independently. const sideKey = (octOffset === 0 ? "L_" : "R_") + letter; const hitUntil = padHitUntil[sideKey] || 0; if (hitUntil > frame) { const hitAlpha = Math.floor(((hitUntil - frame) / 12) * 180); ink(255, 255, 255, hitAlpha); box(x, y, btnW, btnH, true); ink(nc[0], nc[1], nc[2], hitAlpha); box(x, y, btnW, btnH, "outline"); } const fg = dark ? (sharp ? 120 : 190) : (sharp ? 100 : 50); ink(fg, fg, fg); } // Recording overlay: the pad being recorded glows pulsing red. Home // records globally → every melodic pad reads red; per-key → just that // pad. Drum pads are excluded (sampling is melodic). if (!isKit && (recording || perKeyRecording === key)) { const pulse = 110 + Math.floor(90 * Math.abs(Math.sin(frame * 0.25))); ink(225, 30, 30, pulse); box(x, y, btnW, btnH, true); ink(255, 90, 90); box(x, y, btnW, btnH, "outline"); ink(255, 255, 255); // white label reads on red } else if (!isKit && wave === "sample" && key && sampleBank[key]?.waveform && btnH > 10) { // Per-pad waveform: a key carrying a custom sample shows its // recorded envelope (precomputed peaks — cheap, drawn once/frame). const wf = sampleBank[key].waveform; const midY = y + btnH / 2; const stepX = btnW / wf.length; const amp = btnH * 0.38; ink(dark ? 130 : 70, dark ? 225 : 150, dark ? 255 : 210, 150); for (let i = 0; i < wf.length; i++) { const h = Math.max(1, Math.floor(wf[i] * amp)); box(Math.floor(x + i * stepX), Math.floor(midY - h), Math.max(1, Math.ceil(stepX)), h * 2, true); } ink(dark ? 200 : 50, dark ? 200 : 50, dark ? 210 : 60); // restore label ink } // Caps follow shift: lowercase by default, uppercase only while // SHIFT is physically held. Sharp/black-key labels stay lower- // case regardless of shift (matches the system fork at // disks/notepat.mjs:4934). Cleaner read at a glance and gives // SHIFT a visible affordance beyond its accent / alt-timbre role. const label = key ? (sharp || !shiftHeld ? key.toLowerCase() : key.toUpperCase()) : ""; write(label, { x: x + 2, y: y + 2, size: 1, font: "font_1" }); if (!drumActive && btnH > 12) { if (isActive) ink(255, 255, 255, 180); else { const sl = dark ? (sharp ? 80 : 120) : (sharp ? 160 : 110); ink(sl, sl, sl); } write(letter + noteOctave, { x: x + 2, y: y + btnH - 12, size: 1, font: "font_1" }); } // In drum mode the bottom label is the drum's full name. if (drumActive && btnH > 12) { if (isActive) ink(255, 255, 255, 210); else { const sl = dark ? (sharp ? 100 : 150) : (sharp ? 120 : 80); ink(sl, sl, sl); } const fullName = (kitNames && kitNames[letter]) || (kitLabels && kitLabels[letter]) || (letter + noteOctave); write(fullName, { x: x + 2, y: y + btnH - 12, size: 1, font: "font_1" }); } // Pressure bar — fills from bottom of pad proportional to analog pressure if (isActive && pressures && key && pressures[key] !== undefined) { const p = pressures[key]; const barH = Math.floor(p * (btnH - 2)); if (barH > 0) { ink(255, 255, 255, 120); box(x + 1, y + btnH - 1 - barH, btnW - 2, barH, true); } } } } } drawGrid(LEFT_GRID, leftX, leftOctaveOffset, "left"); drawGrid(RIGHT_GRID, rightX, rightOctaveOffset, "right"); // Auxiliary-key legend — tiny pads showing the side-keys that aren't // in the main 4×3 grid but are still bound to notes: // z / x → below-octave A♯ and B (LEFT kit) // ; ' ] → above-octave C, C♯, D (RIGHT kit) // Placed immediately below the main pad row, aligned with their // respective grid sides so you can see what's hooked to the side // of the keyboard at a glance. { const auxH = 10; const auxW = 14; const auxGap = 1; const auxY = gridTop + gridH + 2; if (auxY + auxH < h - 2) { const drawAuxPad = (px, key, letter) => { const nc = noteColor(letter); const isActive = sounds[key] !== undefined; const trailInfo = trail[key]; if (isActive) { ink(nc[0], nc[1], nc[2]); } else if (trailInfo && trailInfo.brightness > 0.05) { const b = trailInfo.brightness; ink(Math.floor(nc[0] * b * 0.4) + (dark ? 12 : 200), Math.floor(nc[1] * b * 0.4) + (dark ? 12 : 200), Math.floor(nc[2] * b * 0.4) + (dark ? 12 : 200)); } else { ink(dark ? Math.floor(nc[0] * 0.25) + 10 : Math.floor(255 - (255 - nc[0]) * 0.18), dark ? Math.floor(nc[1] * 0.25) + 10 : Math.floor(255 - (255 - nc[1]) * 0.18), dark ? Math.floor(nc[2] * 0.25) + 12 : Math.floor(255 - (255 - nc[2]) * 0.18)); } box(px, auxY, auxW, auxH, true); // Bottom accent strip — matches grid color ink(Math.floor(nc[0] * 0.6), Math.floor(nc[1] * 0.6), Math.floor(nc[2] * 0.6), 220); box(px, auxY + auxH - 1, auxW, 1, true); // Key label (tiny, centered) const keyCol = isActive ? (dark ? 20 : 255) : (dark ? 220 : 40); ink(keyCol, keyCol, keyCol, 230); const lx = px + Math.floor((auxW - 4) / 2); write(key, { x: lx, y: auxY + 2, size: 1, font: "font_1" }); }; // LEFT side: z (=-A#, below-octave A#) and x (=-B, below-octave B) // Tucked into the bottom-left corner, just under the left grid's // leftmost column so the spatial relationship to the grid reads. drawAuxPad(leftX, "z", "a#"); drawAuxPad(leftX + auxW + auxGap, "x", "b"); // RIGHT side: ; ' ] (above-octave C, C#, D) tucked into the // bottom-right corner, aligned with the right grid's rightmost // column so the "up-octave" continuation reads visually. const r0x = rightX + gridW - (auxW * 3 + auxGap * 2); drawAuxPad(r0x, ";", "c"); drawAuxPad(r0x + (auxW + auxGap), "'", "c#"); drawAuxPad(r0x + (auxW + auxGap) * 2, "]", "d"); } } // === DRUM INSPECTOR === // When a drum pad fires, its per-voice synth params are captured. We // render them just above the grid so you can see exactly what builds // up each sound. Fades out over ~5 seconds. if (lastDrumInspect && frame < lastDrumInspect.until) { const dark2 = isDark(); const remaining = lastDrumInspect.until - frame; const alpha = Math.min(255, Math.max(40, Math.round(remaining * 1.2))); const rowH = 10; const rowGap = 1; const totalH = rowH * 2 + rowGap + 2; const insY = Math.max(0, gridTop - totalH - 2); // Background panel ink(0, 0, 0, Math.floor(alpha * 0.6)); box(0, insY, w, totalH, true); // Header: drum name + voice count const voices = lastDrumInspect.voices; const header = `${lastDrumInspect.name.toUpperCase()} · ${voices.length} voice${voices.length === 1 ? "" : "s"}`; ink(dark2 ? 255 : 20, dark2 ? 220 : 40, dark2 ? 140 : 80, alpha); write(header, { x: 3, y: insY + 1, size: 1, font: "font_1" }); // Voice cards row const cardY = insY + rowH + rowGap; const cardW = Math.max(32, Math.floor((w - 6) / Math.max(1, voices.length))); // Color per wave type const typeColors = { sine: [120, 200, 255], triangle: [120, 255, 180], square: [255, 220, 120], sawtooth: [255, 140, 80], noise: [220, 220, 230], }; for (let i = 0; i < voices.length; i++) { const v = voices[i]; const cx = 3 + i * cardW; const col = typeColors[v.type] || [180, 180, 200]; // Card background ink(col[0], col[1], col[2], Math.floor(alpha * 0.3)); box(cx, cardY, cardW - 1, rowH, true); // Text: abbreviated params const toneLabel = (v.tone !== undefined && v.tone !== null) ? (v.tone >= 1000 ? (v.tone / 1000).toFixed(1) + "k" : Math.round(v.tone).toString()) : "?"; const atkMs = Math.round((v.attack || 0) * 1000); const dcyMs = Math.round((v.decay || 0) * 1000); const volPct = Math.round((v.volume || 0) * 100); const typeChar = (v.type || "?").slice(0, 1).toUpperCase(); const label = `${typeChar}${toneLabel} a${atkMs} d${dcyMs} v${volPct}`; ink(col[0], col[1], col[2], alpha); write(label, { x: cx + 1, y: cardY + 1, size: 1, font: "font_1" }); } } else if (lastDrumInspect) { lastDrumInspect = null; } // === Gun inspector === // Mirrors the drum inspector style. One row of small cards: header // (weapon · model) + a layer card per param entry. Helps A/B between // warA and warB by showing exactly which synth path you're hearing. // For PHYSICAL voices we also draw a 2.5D side-view of the bore + a // pressure wave bouncing between the breech and muzzle, so the // physically-modeled cavity is also visually legible. if (lastWarInspect && frame < lastWarInspect.until) { const dark2 = isDark(); const remaining = lastWarInspect.until - frame; const alpha = Math.min(255, Math.max(40, Math.round(remaining * 1.2))); const rowH = 10; const rowGap = 1; const vizH = lastWarInspect.model === "physical" ? 24 : 0; const totalH = rowH * 2 + rowGap + 2 + vizH; const insY = Math.max(0, gridTop - totalH - 2); ink(0, 0, 0, Math.floor(alpha * 0.6)); box(0, insY, w, totalH, true); const modelChar = lastWarInspect.model === "physical" ? "P" : "C"; const header = `${lastWarInspect.name.toUpperCase()} · ${modelChar === "P" ? "PHYSICAL" : "CLASSIC"}`; // Color cue: warmer for classic (orange), cooler for physical (cyan). const tint = modelChar === "P" ? [120, 220, 240] : [255, 180, 90]; ink(tint[0], tint[1], tint[2], alpha); write(header, { x: 3, y: insY + 1, size: 1, font: "font_1" }); const weapon = lastWarInspect.name; const modelKey = lastWarInspect.model; const keys = INSPECTOR_KEYS[modelKey] || []; const cardY = insY + rowH + rowGap; const cardW = Math.max(36, Math.floor((w - 6) / Math.max(1, keys.length))); warInspectCardRects = []; for (let i = 0; i < keys.length; i++) { const k = keys[i]; const val = paramValue(weapon, modelKey, k); const ovKey = `${weapon}/${modelKey}/${k}`; const isEdited = ovKey in WAR_PARAM_OVERRIDES; const isDragging = editingWarCard?.weapon === weapon && editingWarCard?.model === modelKey && editingWarCard?.key === k; const cx = 3 + i * cardW; const bgFill = isDragging ? 0.7 : (isEdited ? 0.5 : 0.3); ink(tint[0], tint[1], tint[2], Math.floor(alpha * bgFill)); box(cx, cardY, cardW - 1, rowH, true); ink(tint[0], tint[1], tint[2], alpha); const shortKey = k.replace(/_amp$/, "amp").replace(/_fc$/, "fc") .replace(/_ms$/, "ms").replace(/_q$/, "Q") .replace(/^body_/, "b").replace(/^boom_/, "bm") .replace(/^crack_/, "cr").replace(/^tail_/, "tl") .replace(/^click_/, "ck").replace(/freq_start/, "f0"); const label = `${shortKey} ${formatParamValue(k, val)}${isEdited ? "*" : ""}`; write(label, { x: cx + 1, y: cardY + 1, size: 1, font: "font_1" }); warInspectCardRects.push({ x: cx, y: cardY, w: cardW - 1, h: rowH, key: k, weapon, model: modelKey, }); } // 2.5D barrel viz — only for physical model. // Side-view cylinder (two parallel lines + breech end), pressure // wave bounces left-to-right with decay. Speed/decay are visualized // (not literal — the actual round-trip would be sub-millisecond) so // the user can see a wave traveling instead of an unreadable blur. if (lastWarInspect.model === "physical") { const vizY = insY + rowH + rowGap + rowH + 1; const cylX = 6; const cylW = w - 12; const cylTop = vizY + 2; const cylBot = vizY + vizH - 4; const cylH = cylBot - cylTop; // Cylinder body ink(tint[0], tint[1], tint[2], Math.floor(alpha * 0.4)); line(cylX, cylTop, cylX + cylW, cylTop); line(cylX, cylBot, cylX + cylW, cylBot); // Breech end (closed) line(cylX, cylTop, cylX, cylBot); // Muzzle end notation ink(tint[0], tint[1], tint[2], Math.floor(alpha * 0.6)); line(cylX + cylW, cylTop - 1, cylX + cylW, cylTop + 1); line(cylX + cylW, cylBot - 1, cylX + cylW, cylBot + 1); // Pressure wave — bounces breech↔muzzle, decays over inspector lifetime. const elapsed = (300 - remaining) / 60; // seconds since trigger const roundTripS = 0.18; // illustrative round-trip period const tInLoop = (elapsed % roundTripS) / roundTripS; // 0..1 const pos = tInLoop < 0.5 ? tInLoop * 2 : 2 - tInLoop * 2; // 0→1→0 const waveX = cylX + Math.round(pos * cylW); // Decay envelope (synced to inspector alpha, slightly faster fall-off). const waveAlpha = Math.min(255, Math.max(0, alpha - Math.round(elapsed * 90))); ink(255, 220, 130, waveAlpha); box(waveX - 1, cylTop + 1, 3, cylH - 2, true); // Body-mode flashes at breech + muzzle on each bounce. const bouncePulse = 1 - Math.abs(0.5 - tInLoop) * 2; // 1 at endpoints const pulseAlpha = Math.floor(waveAlpha * bouncePulse * 0.6); ink(255, 180, 80, pulseAlpha); box(cylX - 2, cylTop - 1, 4, cylH + 2, true); // breech glow box(cylX + cylW - 2, cylTop - 1, 4, cylH + 2, true); // muzzle glow } } else if (lastWarInspect) { lastWarInspect = null; warInspectCardRects = null; editingWarCard = null; } // === PER-SIDE MASTER VOLUME SLIDERS === // Thin vertical bars flanking each grid. Drag to set the master volume // for that side's notes and drums. Fill rises from the bottom so it // feels like a level meter at rest. const drawVSlider = (sx, sy, sw, sh, value, active, dragging, accent) => { // Track background ink(dark ? 24 : 226, dark ? 24 : 226, dark ? 30 : 232); box(sx, sy, sw, sh, true); // Fill (from bottom up) const fillH = Math.max(0, Math.floor(sh * Math.max(0, Math.min(1, value)))); if (fillH > 0) { ink(accent[0], accent[1], accent[2], dragging ? 255 : 210); box(sx, sy + sh - fillH, sw, fillH, true); } // Outline ink(dark ? 70 : 160, dark ? 70 : 160, dark ? 85 : 170, active ? 255 : 180); box(sx, sy, sw, sh, "outline"); // Unity tick mark const unityY = sy + Math.floor(sh * (1 - 1.0)); if (unityY >= sy && unityY < sy + sh) { ink(dark ? 140 : 100, dark ? 140 : 100, dark ? 150 : 110, 160); box(sx, unityY, sw, 1, true); } }; const leftSliderX = Math.max(0, leftX - masterSliderW - 1); const rightSliderX = Math.min(w - masterSliderW, rightX + (cols * btnW) + 1); const leftAccent = [60, 200, 180]; // teal const rightAccent = [220, 140, 90]; // warm amber drawVSlider(leftSliderX, gridTop, masterSliderW, gridH, leftMasterVol, hoverX >= leftSliderX && hoverX < leftSliderX + masterSliderW && hoverY >= gridTop && hoverY < gridTop + gridH, leftVolDragging, leftAccent); drawVSlider(rightSliderX, gridTop, masterSliderW, gridH, rightMasterVol, hoverX >= rightSliderX && hoverX < rightSliderX + masterSliderW && hoverY >= gridTop && hoverY < gridTop + gridH, rightVolDragging, rightAccent); // Expose for act() hit-testing globalThis.__leftMasterSlider = { x: leftSliderX, y: gridTop, w: masterSliderW, h: gridH }; globalThis.__rightMasterSlider = { x: rightSliderX, y: gridTop, w: masterSliderW, h: gridH }; // Transient percussion toggle notice (shown near top of grid) if (percussionNotice && frame < percussionNotice.until) { const dark2 = isDark(); const remaining = percussionNotice.until - frame; const alpha = Math.min(255, Math.round(remaining * 3)); const txt = percussionNotice.text; const tw = Math.max(1, txt.length) * 6 + 8; const tx = Math.floor((w - tw) / 2); const ty = gridTop + 2; ink(0, 0, 0, Math.floor(alpha * 0.6)); box(tx - 1, ty - 1, tw + 2, 12, true); ink(dark2 ? 40 : 255, dark2 ? 40 : 255, dark2 ? 55 : 255, Math.floor(alpha * 0.9)); box(tx, ty, tw, 10, true); ink(dark2 ? 240 : 20, dark2 ? 220 : 20, dark2 ? 180 : 60, alpha); box(tx, ty, tw, 10, "outline"); ink(dark2 ? 240 : 30, dark2 ? 230 : 30, dark2 ? 200 : 60, alpha); write(txt, { x: tx + 4, y: ty + 1, size: 1, font: "font_1" }); } else if (percussionNotice) { percussionNotice = null; } // Envelope / arrow-key notice (attack, decay, octave, volume) if (envelopeNotice && frame < envelopeNotice.until) { const dark2 = isDark(); const remaining = envelopeNotice.until - frame; const alpha = Math.min(255, Math.round(remaining * 3.5)); const txt = envelopeNotice.text; const tw = Math.max(1, txt.length) * 6 + 12; const tx = Math.floor((w - tw) / 2); const ty = gridTop + gridH + 4; // Background ink(0, 0, 0, Math.floor(alpha * 0.7)); box(tx - 1, ty - 1, tw + 2, 14, true); // Colored fill based on type const isAttack = envelopeNotice.param === "attack"; const isDecay = envelopeNotice.param === "decay"; const isArrow = envelopeNotice.param === "arrow"; const r2 = isAttack ? 80 : isDecay ? 40 : isArrow ? 60 : 40; const g2 = isAttack ? 40 : isDecay ? 80 : isArrow ? 50 : 40; const b2 = isAttack ? 120 : isDecay ? 120 : isArrow ? 100 : 55; ink(dark2 ? r2 : 220, dark2 ? g2 : 220, dark2 ? b2 : 240, Math.floor(alpha * 0.9)); box(tx, ty, tw, 12, true); // Envelope shape icon (small 3-segment drawing) if (isAttack || isDecay) { const mode = envelopeNotice.mode; const ix = tx + 2, iy = ty + 2, ih = 8, iw = 12; ink(dark2 ? 255 : 30, dark2 ? 220 : 30, dark2 ? 180 : 60, alpha); if (isAttack) { // Attack icon: vertical rise speed varies by mode const peakX = mode === "zero" ? 1 : mode === "short" ? 4 : 9; line(ix, iy + ih, ix + peakX, iy); // rise line(ix + peakX, iy, ix + iw, iy); // sustain } else { // Decay icon: peak then fall speed varies const fallStart = mode === "zero" ? 1 : mode === "short" ? 4 : 9; line(ix, iy, ix + (iw - fallStart), iy); // sustain line(ix + (iw - fallStart), iy, ix + iw, iy + ih); // fall } } // Text const textX = (isAttack || isDecay) ? tx + 16 : tx + 4; ink(dark2 ? 240 : 30, dark2 ? 230 : 30, dark2 ? 200 : 60, alpha); write(txt, { x: textX, y: ty + 2, size: 1, font: "font_1" }); } else if (envelopeNotice) { envelopeNotice = null; } // GM instrument readout — program name flash on selection (number keys). // // POSITION: its own dedicated line just BELOW the DJ strip. The top bar // (y 0..topBarH) is already full — QR (left), the size-2 "notepat.com" // wordmark (x≈32..120, y≈5..19), the NuPhy badge, and the right-side // clock/battery/volume cluster. Anchoring the readout in the top bar's // lower-left row (the old `topBarH-13`) overlapped the wordmark, and the // even-older `gridTop+gridH+18` fell off the bottom of the screen. The // wave-button row IS the instrument selector, so the current melodic // instrument reads naturally on the line directly beneath that whole // selector + DJ block. We recompute the layout constants here (they are // defined later in paint, after this block runs): sliders start at // settingsY=topBarH with sliderH=12 over 7 rows, then the 14px wave row, // then the 16px DJ strip — so the first clean line is topBarH + 12*7 + // 14 + 16. Clamp into [0, h) and away from the record/waveform strip so // it can never clip off-screen or collide on short displays. const gmReadoutY = (() => { const djStripBottom = topBarH + 12 * 7 + 14 + 16; // ≈ 148 // Keep above the record strip (top ≈ gridTop - 24) and on-screen. const maxY = Math.max(topBarH, h - 14); return Math.max(topBarH + 1, Math.min(djStripBottom + 2, maxY)); })(); if (gmNotice && frame < gmNotice.until) { const dark2 = isDark(); const remaining = gmNotice.until - frame; const alpha = Math.min(255, Math.round(remaining * 2.2)); const txt = gmNotice.text; const tw = Math.max(1, txt.length) * 6 + 12; // Render on the same dedicated INSTRUMENT line as the persistent readout // below — its own row just under the DJ strip, clear of the top-bar // wordmark/QR/NuPhy/status cluster (all of which live in y < topBarH). const tx = Math.max(2, Math.min(w - tw - 1, 2)); const ty = gmReadoutY; ink(0, 0, 0, Math.floor(alpha * 0.7)); box(tx - 1, ty - 1, tw + 2, 14, true); ink(dark2 ? 40 : 220, dark2 ? 60 : 230, dark2 ? 90 : 250, Math.floor(alpha * 0.9)); box(tx, ty, tw, 12, true); ink(dark2 ? 120 : 30, dark2 ? 220 : 60, dark2 ? 255 : 90, alpha); box(tx, ty, tw, 12, "outline"); ink(dark2 ? 220 : 20, dark2 ? 240 : 40, dark2 ? 255 : 80, alpha); write(txt, { x: tx + 4, y: ty + 2, size: 1, font: "font_1" }); } else if (gmNotice) { gmNotice = null; } // Persistent INSTRUMENT readout — always-on status line so the player can // see the current melodic voice (GM program, MIDI passthru, or legacy // wave) at a glance, and watch the program number FORM as digits are typed. // // PREVIOUS BUGS: (1) rendered at `gridTop + gridH + 18` ≈ h + 1 — one pixel // BELOW the screen, never visible; (2) then anchored at top-bar `topBarH-13` // which OVERLAPPED the size-2 "notepat.com" wordmark. It now renders on its // own dedicated INSTRUMENT line below the DJ strip (gmReadoutY), left- // aligned at x=2, on-screen and clear of the wordmark/QR/NuPhy/status bar. if (!(gmNotice && frame < gmNotice.until)) { const dark2 = isDark(); // Mid-entry? gmDigitBuffer holds the partial number and gmDigitLastMs is // within the 0.8s commit window. Show it forming with a blinking caret. const typingActive = gmDigitBuffer.length > 0 && (Date.now() - gmDigitLastMs) <= 800; let label, accent; if (typingActive) { const caret = (frame % 30) < 15 ? "_" : " "; // ~0.5s blink label = "prog " + gmDigitBuffer + caret; // e.g. "prog 12_" accent = true; } else if (gmPassthrough) { label = "MIDI PASSTHRU"; accent = false; } else if (gmProgram !== null) { const num = String(gmProgram + 1).padStart(3, "0"); label = num + " " + (GM_PROGRAM_NAMES[gmProgram] || ""); accent = false; } else { // No GM: fall back to the legacy freely-cycled wave name. label = "wave " + (wave || "—"); accent = false; } const tw = Math.max(1, label.length) * 6 + 12; // Dedicated instrument line just below the DJ strip. Left-aligned at x=2; // clamp right edge so even long GM names ("049 String Ensemble 1") stay // on-screen. ty is the shared gmReadoutY computed above (in [0, h)). const tx = Math.max(2, Math.min(2, w - tw - 1)); const ty = gmReadoutY; ink(0, 0, 0, 140); box(tx - 1, ty - 1, tw + 2, 14, true); if (accent) { // Live-typing: warm-highlight fill + bright outline so it reads as // "entry in progress" rather than a committed selection. ink(dark2 ? 70 : 250, dark2 ? 55 : 235, dark2 ? 20 : 180, 220); box(tx, ty, tw, 12, true); ink(dark2 ? 255 : 200, dark2 ? 230 : 140, dark2 ? 90 : 40); box(tx, ty, tw, 12, "outline"); ink(dark2 ? 255 : 30, dark2 ? 245 : 30, dark2 ? 170 : 70); } else { ink(dark2 ? 28 : 235, dark2 ? 32 : 238, dark2 ? 42 : 244, 200); box(tx, ty, tw, 12, true); ink(dark2 ? 70 : 200, dark2 ? 90 : 205, dark2 ? 110 : 210); box(tx, ty, tw, 12, "outline"); ink(dark2 ? 170 : 60, dark2 ? 190 : 70, dark2 ? 210 : 90); } write(label, { x: tx + 4, y: ty + 2, size: 1, font: "font_1" }); } // === SLIDERS: fx mix, echo, pitch, bitcrush + per-effect X/Y routing + reset === const settingsY = topBarH; const sliderH = 12; const axisBoxSize = 10; const axisGap = 2; // Three boxes to the right of each slider now: X toggle, Y toggle, 0 reset. const axisAreaW = axisBoxSize * 3 + axisGap * 2; const fxRows = {}; const drawAxisToggle = (rect, label, active, accent) => { ink(dark ? 20 : 236, dark ? 20 : 236, dark ? 25 : 242); box(rect.x, rect.y, rect.w, rect.h, true); if (active) { ink(accent[0], accent[1], accent[2], trackpadFX ? 240 : 150); box(rect.x, rect.y, rect.w, rect.h, true); } ink(dark ? 70 : 150, dark ? 70 : 150, dark ? 85 : 165, active ? 230 : 170); box(rect.x, rect.y, rect.w, rect.h, "outline"); if (active) ink(dark ? 10 : 250, dark ? 10 : 250, dark ? 10 : 250); else ink(dark ? 120 : 105, dark ? 120 : 105, dark ? 135 : 120); write(label, { x: rect.x + 2, y: rect.y + 1, size: 1, font: "font_1" }); }; // Reset button: a small grey-outlined "0" square. Click → reset that // effect's mix to its "zeroed" value (0 for most; 0.5 for volume's // unity tick; 0 for pitch's center). Visual is intentionally muted so // the eye doesn't confuse it with the X/Y trackpad toggles. const drawResetButton = (rect) => { ink(dark ? 24 : 232, dark ? 24 : 232, dark ? 30 : 238); box(rect.x, rect.y, rect.w, rect.h, true); ink(dark ? 80 : 150, dark ? 80 : 150, dark ? 95 : 165, 180); box(rect.x, rect.y, rect.w, rect.h, "outline"); ink(dark ? 140 : 110, dark ? 140 : 110, dark ? 155 : 125); write("0", { x: rect.x + 3, y: rect.y + 1, size: 1, font: "font_1" }); }; // FX mix slider (dry/wet for the entire chain) — now gets a reset // button like the other rows (zero returns to FULLY WET, not 0, since // fx=1 is the natural "unity" state where all effects pass through). { const sliderY = settingsY; const sliderW = w - axisAreaW; const fxHovered = hoverY >= sliderY && hoverY < sliderY + sliderH; ink(dark ? (fxHovered ? 40 : 25) : (fxHovered ? 220 : 235), dark ? (fxHovered ? 40 : 25) : (fxHovered ? 220 : 235), dark ? (fxHovered ? 45 : 28) : (fxHovered ? 225 : 238)); box(0, sliderY, w, sliderH, true); const fillW = Math.floor(fxMix * sliderW); if (fillW > 0) { ink(120, 200, 80, 180); box(0, sliderY, fillW, sliderH, true); } if (fxMix > 0.005) { const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(fxMix * sliderW))); ink(180, 255, 120, 220); box(knobX - 1, sliderY, 3, sliderH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); write("fx " + Math.round(fxMix * 100) + "%", { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); if (trackpadFX) { ink(120, 220, 120); write("\\", { x: sliderW - 8, y: sliderY + 2, size: 1, font: "font_1" }); } // The fx dry/wet bar doesn't have trackpad X/Y routing (it's the // global wet mix, not an effect parameter), but a reset "0" button // at the row tail restores it to the default 100% wet state. const resetBox = { x: sliderW + axisBoxSize * 2 + axisGap * 2, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; drawResetButton(resetBox); fxRows.fx = { y: sliderY, h: sliderH, sliderX: 0, sliderW, resetBox }; } // Echo slider + X/Y assignment boxes { const sliderY = settingsY + sliderH; const sliderW = w - axisAreaW; const echoHovered = hoverY >= sliderY && hoverY < sliderY + sliderH; ink(dark ? (echoHovered ? 40 : 25) : (echoHovered ? 220 : 235), dark ? (echoHovered ? 40 : 25) : (echoHovered ? 220 : 235), dark ? (echoHovered ? 45 : 28) : (echoHovered ? 225 : 238)); box(0, sliderY, w, sliderH, true); const fillW = Math.floor(echoMix * sliderW); if (fillW > 0) { ink(80, 120, 220, trackpadFX ? 240 : 180); box(0, sliderY, fillW, sliderH, true); } if (echoMix > 0.005) { const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(echoMix * sliderW))); ink(140, 180, 255, 220); box(knobX - 1, sliderY, 3, sliderH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); write("echo " + Math.round(echoMix * 100) + "%", { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const resetBox = { x: sliderW + (axisBoxSize + axisGap) * 2, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; drawResetButton(resetBox); drawAxisToggle(xBox, "x", !!trackpadEffectBindings.echo.x, [80, 120, 220]); drawAxisToggle(yBox, "y", !!trackpadEffectBindings.echo.y, [80, 120, 220]); fxRows.echo = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox, resetBox }; } // Pitch slider + X/Y assignment boxes { const sliderY = settingsY + sliderH * 2; const sliderW = w - axisAreaW; const pitchHovered = hoverY >= sliderY && hoverY < sliderY + sliderH; ink(dark ? (pitchHovered ? 40 : 25) : (pitchHovered ? 220 : 235), dark ? (pitchHovered ? 40 : 25) : (pitchHovered ? 220 : 235), dark ? (pitchHovered ? 45 : 28) : (pitchHovered ? 225 : 238)); box(0, sliderY, w, sliderH, true); const centerX = Math.floor(sliderW / 2); ink(dark ? 40 : 220, dark ? 40 : 220, dark ? 45 : 225); box(centerX, sliderY, 1, sliderH, true); const pitchX = Math.floor((pitchShift + 1) / 2 * sliderW); if (Math.abs(pitchShift) > 0.005) { ink(200, 100, 160, trackpadFX ? 240 : 180); const fx = Math.min(centerX, pitchX); const fw = Math.abs(pitchX - centerX); box(fx, sliderY, fw, sliderH, true); } if (Math.abs(pitchShift) > 0.005) { ink(255, 140, 180, 220); box(Math.max(1, Math.min(sliderW - 3, pitchX)) - 1, sliderY, 3, sliderH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); const cents = Math.round(pitchShift * 1200); write("pitch " + (cents >= 0 ? "+" : "") + cents + "c", { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const resetBox = { x: sliderW + (axisBoxSize + axisGap) * 2, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; drawResetButton(resetBox); drawAxisToggle(xBox, "x", !!trackpadEffectBindings.pitch.x, [200, 100, 160]); drawAxisToggle(yBox, "y", !!trackpadEffectBindings.pitch.y, [200, 100, 160]); fxRows.pitch = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox, resetBox }; } // Bitcrush slider + X/Y assignment boxes { const sliderY = settingsY + sliderH * 3; const sliderW = w - axisAreaW; const crushHovered = hoverY >= sliderY && hoverY < sliderY + sliderH; ink(dark ? (crushHovered ? 40 : 25) : (crushHovered ? 220 : 235), dark ? (crushHovered ? 40 : 25) : (crushHovered ? 220 : 235), dark ? (crushHovered ? 45 : 28) : (crushHovered ? 225 : 238)); box(0, sliderY, w, sliderH, true); const fillW = Math.floor(bitcrushMix * sliderW); if (fillW > 0) { ink(240, 150, 70, trackpadFX ? 245 : 190); box(0, sliderY, fillW, sliderH, true); } if (bitcrushMix > 0.005) { const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(bitcrushMix * sliderW))); ink(255, 220, 130, 220); box(knobX - 1, sliderY, 3, sliderH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); write("crush " + Math.round(bitcrushMix * 100) + "%", { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const resetBox = { x: sliderW + (axisBoxSize + axisGap) * 2, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; drawResetButton(resetBox); drawAxisToggle(xBox, "x", !!trackpadEffectBindings.crush.x, [240, 150, 70]); drawAxisToggle(yBox, "y", !!trackpadEffectBindings.crush.y, [240, 150, 70]); fxRows.crush = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox, resetBox }; } // Master volume slider — user gain 0..200% (50% on slider = unity). // Sits below crush so it feels like a "last stage" like a mixing board // master fader. A tick at 50% marks unity for visual reference. { const sliderY = settingsY + sliderH * 4; const sliderW = w - axisAreaW; const hov = hoverY >= sliderY && hoverY < sliderY + sliderH; ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235), dark ? (hov ? 40 : 25) : (hov ? 220 : 235), dark ? (hov ? 45 : 28) : (hov ? 225 : 238)); box(0, sliderY, w, sliderH, true); const fillW = Math.floor(masterVolMix * sliderW); if (fillW > 0) { ink(100, 220, 150, trackpadFX ? 240 : 180); box(0, sliderY, fillW, sliderH, true); } // Unity tick at the 50% position. const unityX = Math.floor(sliderW * 0.5); ink(dark ? 90 : 160, dark ? 110 : 180, dark ? 110 : 170, 200); box(unityX, sliderY, 1, sliderH, true); if (masterVolMix > 0.005) { const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(masterVolMix * sliderW))); ink(160, 255, 190, 220); box(knobX - 1, sliderY, 3, sliderH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); write("vol " + Math.round(masterVolMix * 200) + "%", { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const resetBox = { x: sliderW + (axisBoxSize + axisGap) * 2, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; drawResetButton(resetBox); drawAxisToggle(xBox, "x", !!trackpadEffectBindings.volume.x, [100, 220, 150]); drawAxisToggle(yBox, "y", !!trackpadEffectBindings.volume.y, [100, 220, 150]); fxRows.volume = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox, resetBox }; } // Drive slider — tanh soft-sat dry/wet (0 = clean, 100% = fully driven). // Subtle warmth in the 10-30% range, obvious distortion above 60%. { const sliderY = settingsY + sliderH * 5; const sliderW = w - axisAreaW; const hov = hoverY >= sliderY && hoverY < sliderY + sliderH; ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235), dark ? (hov ? 40 : 25) : (hov ? 220 : 235), dark ? (hov ? 45 : 28) : (hov ? 225 : 238)); box(0, sliderY, w, sliderH, true); const fillW = Math.floor(driveMix * sliderW); if (fillW > 0) { ink(220, 90, 70, trackpadFX ? 240 : 180); box(0, sliderY, fillW, sliderH, true); } if (driveMix > 0.005) { const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(driveMix * sliderW))); ink(255, 160, 120, 220); box(knobX - 1, sliderY, 3, sliderH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); write("drive " + Math.round(driveMix * 100) + "%", { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const resetBox = { x: sliderW + (axisBoxSize + axisGap) * 2, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; drawResetButton(resetBox); drawAxisToggle(xBox, "x", !!trackpadEffectBindings.drive.x, [220, 90, 70]); drawAxisToggle(yBox, "y", !!trackpadEffectBindings.drive.y, [220, 90, 70]); fxRows.drive = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox, resetBox }; } // Wobble slider — LFO-modulated short delay dry/wet (0 = clean, 100% = full flange). // Purple palette to distinguish from drive (red/orange) and volume (green). { const sliderY = settingsY + sliderH * 6; const sliderW = w - axisAreaW; const hov = hoverY >= sliderY && hoverY < sliderY + sliderH; ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235), dark ? (hov ? 40 : 25) : (hov ? 220 : 235), dark ? (hov ? 45 : 28) : (hov ? 225 : 238)); box(0, sliderY, w, sliderH, true); const fillW = Math.floor(wobbleMix * sliderW); if (fillW > 0) { ink(150, 100, 220, trackpadFX ? 240 : 180); box(0, sliderY, fillW, sliderH, true); } if (wobbleMix > 0.005) { const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(wobbleMix * sliderW))); ink(200, 160, 255, 220); box(knobX - 1, sliderY, 3, sliderH, true); } ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); write("wobble " + Math.round(wobbleMix * 100) + "%", { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; const resetBox = { x: sliderW + (axisBoxSize + axisGap) * 2, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; drawResetButton(resetBox); drawAxisToggle(xBox, "x", !!trackpadEffectBindings.wobble.x, [150, 100, 220]); drawAxisToggle(yBox, "y", !!trackpadEffectBindings.wobble.y, [150, 100, 220]); fxRows.wobble = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox, resetBox }; } globalThis.__fxRows = fxRows; const waveRowY = settingsY + sliderH * 7; const waveRowH = 14; // === WAVE TYPE BUTTONS (below sliders, modular GUI) === { const waveLabels = ["sine", "tri", "saw", "square", "pno", "harp", "whist", "sample"]; const octBtnW = 22; // octave button // REC button (rightmost, always visible). Must hold a 4px red dot + 18px // "REC" glyphs + insets without clipping the screen's right edge: dot at // +3..+7, text at +9..+27, so the button needs ≥29px and a 1px right // margin. Previously 24px clipped "REC" by ~3px off-screen. const recBtnW = 30; const waveAreaW = w - octBtnW - recBtnW - 2; const btnW2 = Math.floor(waveAreaW / wavetypes.length); // Wave type colors: sin=blue, tri=green, saw=orange, sq=purple, pno=ivory, // harp=gold, whistle=bone, sample=cyan const WAVE_COLORS = [ [60, 120, 255], // sine — blue [60, 200, 80], // triangle — green [255, 150, 40], // sawtooth — orange [160, 80, 220], // square — purple [245, 235, 215], // piano — ivory (key color) [220, 170, 70], // harp — warm gold (bronze string) [220, 220, 150], // whistle — bone [40, 200, 200], // sample — cyan ]; // Draw each wave button for (let i = 0; i < wavetypes.length; i++) { const bx = i * btnW2; const isActive = wave === wavetypes[i]; const isHov = hoverX >= bx && hoverX < bx + btnW2 && hoverY >= waveRowY && hoverY < waveRowY + waveRowH; const wc = WAVE_COLORS[i]; if (isActive) { ink(dark ? Math.floor(wc[0] * 0.4) + 20 : Math.floor(255 - (255 - wc[0]) * 0.3), dark ? Math.floor(wc[1] * 0.4) + 20 : Math.floor(255 - (255 - wc[1]) * 0.3), dark ? Math.floor(wc[2] * 0.4) + 20 : Math.floor(255 - (255 - wc[2]) * 0.3)); box(bx, waveRowY, btnW2, waveRowH, true); ink(dark ? 240 : 20, dark ? 250 : 20, dark ? 255 : 30); } else if (isHov) { ink(dark ? Math.floor(wc[0] * 0.2) + 15 : Math.floor(255 - (255 - wc[0]) * 0.15), dark ? Math.floor(wc[1] * 0.2) + 15 : Math.floor(255 - (255 - wc[1]) * 0.15), dark ? Math.floor(wc[2] * 0.2) + 15 : Math.floor(255 - (255 - wc[2]) * 0.15)); box(bx, waveRowY, btnW2, waveRowH, true); ink(dark ? 180 : 70, dark ? 180 : 70, dark ? 190 : 80); } else { ink(dark ? Math.floor(wc[0] * 0.18) + 15 : Math.floor(255 - (255 - wc[0]) * 0.12), dark ? Math.floor(wc[1] * 0.18) + 15 : Math.floor(255 - (255 - wc[1]) * 0.12), dark ? Math.floor(wc[2] * 0.18) + 15 : Math.floor(255 - (255 - wc[2]) * 0.12)); box(bx, waveRowY, btnW2, waveRowH, true); ink(dark ? Math.floor(wc[0] * 0.55) + 60 : Math.floor(wc[0] * 0.7) + 50, dark ? Math.floor(wc[1] * 0.55) + 60 : Math.floor(wc[1] * 0.7) + 50, dark ? Math.floor(wc[2] * 0.55) + 60 : Math.floor(wc[2] * 0.7) + 50); } // Color accent strip at bottom of each button ink(dark ? Math.floor(wc[0] * 0.6) : wc[0], dark ? Math.floor(wc[1] * 0.6) : wc[1], dark ? Math.floor(wc[2] * 0.6) : wc[2], isActive ? 200 : 80); box(bx, waveRowY + waveRowH - 2, btnW2, 2, true); // Separator line between buttons if (i > 0) { ink(dark ? 40 : 190, dark ? 40 : 190, dark ? 45 : 195); box(bx, waveRowY, 1, waveRowH, true); } // Label — stays constant regardless of Shift state. The Shift // "alternate sound" mode is now indicated globally via the // screen-edge outline (drawn at the end of paint), so per-button // label renaming would be redundant and feel local to one // instrument. Individual kits still apply their own Shift // variation (harp → short pluck, whistle → messy blow, etc.) // downstream of this visual label. const label = waveLabels[i]; const lx = bx + Math.floor((btnW2 - label.length * 6) / 2); write(label, { x: lx, y: waveRowY + 3, size: 1, font: "font_1" }); } // Octave button (always visible, second-from-right). Tap to step. const obx = w - recBtnW - octBtnW - 1; const octHov = hoverX >= obx && hoverX < obx + octBtnW && hoverY >= waveRowY && hoverY < waveRowY + waveRowH; { ink(dark ? (octHov ? 50 : 28) : (octHov ? 200 : 225), dark ? (octHov ? 50 : 28) : (octHov ? 200 : 225), dark ? (octHov ? 55 : 32) : (octHov ? 205 : 230)); box(obx, waveRowY, octBtnW, waveRowH, true); ink(dark ? 40 : 190, dark ? 40 : 190, dark ? 45 : 195); box(obx, waveRowY, 1, waveRowH, true); ink(dark ? 140 : 100, dark ? 140 : 100, dark ? 150 : 110); write("o:" + octave, { x: obx + 3, y: waveRowY + 3, size: 1, font: "font_1" }); // Hold indicator: shown when Enter is held (arming) OR when there // are latched notes. While Enter is held the label is "HOLD" with // a pulsing green tint; otherwise "H:n" with the current count. if (enterHeld || heldKeys.size > 0) { const label = enterHeld ? "HOLD" + (heldKeys.size > 0 ? " " + heldKeys.size : "") : "H:" + heldKeys.size; const holdW = label.length * 6 + 4; const hbx = obx - holdW - 2; if (enterHeld) { // Pulsing green while armed const pulse = (Math.sin(frame * 0.25) + 1) * 0.5; // 0..1 const g = Math.round(140 + pulse * 80); ink(dark ? 30 : 140, g, dark ? 40 : 160); box(hbx, waveRowY, holdW, waveRowH, true); ink(0, 0, 0); write(label, { x: hbx + 2, y: waveRowY + 3, size: 1, font: "font_1" }); } else { ink(dark ? 80 : 180, dark ? 40 : 100, dark ? 40 : 100); box(hbx, waveRowY, holdW, waveRowH, true); ink(255, dark ? 180 : 60, dark ? 120 : 40); write(label, { x: hbx + 2, y: waveRowY + 3, size: 1, font: "font_1" }); } } } // REC button (always visible, rightmost). Press-and-hold to record into // the global sample slot. Auto-switches wave to "sample" so playback // hears the capture, regardless of which wave was active when pressed. const rbx = w - recBtnW; const recHov = hoverX >= rbx && hoverY >= waveRowY && hoverY < waveRowY + waveRowH; if (recording) { // Recording — red pulse with countdown bar const recElapsed = (Date.now() - recStartTime) / 1000; const recRemain = Math.max(0, MAX_REC_SECS - recElapsed); const recFrac = recRemain / MAX_REC_SECS; ink(200, 30, 30); box(rbx, waveRowY, recBtnW, waveRowH, true); const barH = 2; const barW = Math.round((recBtnW - 2) * recFrac); ink(255, 80, 80); box(rbx + 1, waveRowY + waveRowH - barH - 1, barW, barH, true); ink(255, 255, 255); write(recRemain.toFixed(1), { x: rbx + 2, y: waveRowY + 2, size: 1, font: "font_1" }); } else { ink(dark ? (recHov ? 80 : 50) : (recHov ? 220 : 240), dark ? (recHov ? 30 : 20) : (recHov ? 200 : 220), dark ? (recHov ? 30 : 20) : (recHov ? 200 : 220)); box(rbx, waveRowY, recBtnW, waveRowH, true); ink(dark ? 40 : 190, dark ? 40 : 190, dark ? 45 : 195); box(rbx, waveRowY, 1, waveRowH, true); ink(dark ? 240 : 200, dark ? 80 : 50, dark ? 80 : 50); // Filled red dot before the label box(rbx + 3, waveRowY + 5, 4, 4, true); ink(dark ? 220 : 160, dark ? 80 : 60, dark ? 80 : 60); write("REC", { x: rbx + 9, y: waveRowY + 3, size: 1, font: "font_1" }); } // Mic level meter in sample mode — compact multi-segment bar next to REC. if (wave === "sample") { const infoY = waveRowY + waveRowH + 1; const level = Math.max(0, Math.min(1, mic.level || 0)); const len = mic.sampleLength || 0; const rate = mic.sampleRate || 0; // Compact label: duration when loaded, nothing otherwise if (len > 0 && rate > 0) { ink(dark ? 120 : 100, dark ? 120 : 100, dark ? 135 : 115); write((len / rate).toFixed(1) + "s", { x: 2, y: infoY + 1, size: 1, font: "font_1" }); } // Multi-segment level meter (spans most of the row, ends at REC button) const meterX = (len > 0 && rate > 0) ? 30 : 2; const meterW = (rbx - 2) - meterX; const meterH = 7; const meterY = infoY + 1; const segments = 16; const segW = Math.max(2, Math.floor((meterW - segments + 1) / segments)); const gap = 1; const lit = Math.round(level * segments); for (let s = 0; s < segments; s++) { const sx = meterX + s * (segW + gap); if (s < lit) { // Green → yellow → red gradient if (s < segments * 0.6) ink(60, 200, 80); else if (s < segments * 0.85) ink(220, 200, 40); else ink(255, 60, 60); } else { ink(dark ? 30 : 215, dark ? 30 : 215, dark ? 35 : 220); } box(sx, meterY, segW, meterH, true); } } // Expose hit zones for act() globalThis.__waveButtons = { y: waveRowY, h: waveRowH, btnW: btnW2, octX: obx, octW: octBtnW, recX: rbx, recW: recBtnW }; } // === DJ DECK STRIP === // One compact line below the wave buttons. Shows play state, track name, // position/duration, speed, BPM. Left icon = play/pause button. Right // portion is a scratch pad (drag to setSpeed(0, v), release to resume). { const dk0 = soundAPI?.deck?.decks?.[0] || {}; const stripY = waveRowY + waveRowH; const stripH = 16; const bgR = dark ? 22 : 232, bgG = dark ? 22 : 232, bgB = dark ? 30 : 236; ink(bgR, bgG, bgB); box(0, stripY, w, stripH, true); ink(dark ? 60 : 180, dark ? 60 : 180, dark ? 70 : 190); line(0, stripY + stripH - 1, w, stripY + stripH - 1); // Play / pause icon (leftmost square) const iconW = 16; const playHov = hoverX >= 0 && hoverX < iconW && hoverY >= stripY && hoverY < stripY + stripH; if (dk0.playing) ink(80, 200, 120, playHov ? 255 : 200); else ink(dark ? 120 : 100, dark ? 120 : 100, dark ? 130 : 110, playHov ? 255 : 180); box(1, stripY + 1, iconW - 2, stripH - 2, true); ink(dark ? 20 : 240, dark ? 20 : 240, dark ? 25 : 245); if (dk0.playing) { // pause icon: two vertical bars box(5, stripY + 4, 2, stripH - 8, true); box(9, stripY + 4, 2, stripH - 8, true); } else { // play icon: triangle approximated with 3 rows for (let yo = 0; yo < 8; yo++) { const rowW = 8 - Math.abs(yo - 3) * 2; if (rowW > 0) box(5, stripY + 4 + yo, rowW, 1, true); } } // Right side: scratch pad zone (dragged to scratch) — subtle hatch pattern const scratchX = iconW + 2; const scratchW = w - scratchX; const scratchHov = !playHov && hoverY >= stripY && hoverY < stripY + stripH; if (djDragging) { ink(200, 120, 60, 150); box(scratchX, stripY + 1, scratchW, stripH - 2, true); } else if (scratchHov) { ink(dark ? 35 : 220, dark ? 35 : 220, dark ? 45 : 228); box(scratchX, stripY + 1, scratchW, stripH - 2, true); } // Text: title / pos / dur / speed / BPM // Empty-state title reflects the WHOLE DJ module state together — // "no usb" and "no tracks" are treated as siblings in one readout // rather than scattered between inline label and transient djMsg. // USB mounted + 0 tracks scanned → "no tracks"; USB disconnected → // "no usb". Track loaded → title from filename. const title = dk0.loaded ? (dk0.title || "?").replace(/\.[^.]+$/, "") : (djFiles.length > 0 ? "tap scan" : (djUsbConnected ? "no tracks" : "no usb")); const pos = djFmt(dk0.position || 0); const dur = djFmt(dk0.duration || 0); const spd = `${(dk0.speed || 1).toFixed(2)}x`; const bpmStr = djDerivedBPM > 0 ? `${djDerivedBPM}bpm` : "--bpm"; // Left text: title (truncated) const maxTitleChars = Math.max(4, Math.floor((scratchW - 100) / 6)); const shortTitle = title.length > maxTitleChars ? title.slice(0, maxTitleChars - 1) + "…" : title; ink(dark ? 220 : 40, dark ? 220 : 40, dark ? 230 : 60); write(shortTitle, { x: scratchX + 4, y: stripY + 4, size: 1, font: "font_1" }); // Right-side meta: pos/dur spd bpm const right = `${pos}/${dur} ${spd} ${bpmStr}`; const rightX = w - right.length * 6 - 4; ink(dark ? 140 : 90, dark ? 150 : 90, dark ? 170 : 110); write(right, { x: rightX, y: stripY + 4, size: 1, font: "font_1" }); // Progress bar (thin) at bottom of strip const prog = dk0.duration > 0 ? (dk0.position / dk0.duration) : 0; ink(dark ? 60 : 190, dark ? 80 : 200, dark ? 120 : 210, 200); box(scratchX, stripY + stripH - 2, Math.max(1, Math.floor(scratchW * prog)), 1, true); // Transient deck message (fades out) if (djMessage && frame - djMessageFrame < 120) { const a = Math.max(0, 255 - Math.floor((frame - djMessageFrame) * 2.5)); ink(220, 180, 80, a); write(djMessage.slice(0, 28), { x: scratchX + 4, y: stripY - 10, size: 1, font: "font_1" }); } // Expose strip layout for act() hit-testing globalThis.__djStrip = { x: 0, y: stripY, w, h: stripH, iconW, scratchX, scratchW }; } } // end activeScreen === "notepat" // WiFi fullscreen password entry if (wifiPasswordMode && wifi) { wifiCursorBlink++; const nets = wifi.networks || []; const net = nets[wifiSelectedIdx]; const ssid = net ? net.ssid : "?"; // Fullscreen dark overlay ink(dark ? 10 : 240, dark ? 10 : 240, dark ? 15 : 245, 250); box(0, 0, w, h, true); // Title: network name in matrix chunky (centered estimate: ~9px per char at scale 2) ink(FG, FG, FG); const titleX = Math.max(10, (w - ssid.length * 18) / 2); write(ssid, { x: titleX, y: h / 2 - 50, size: 2, font: "matrix" }); // "enter password" label ink(FG_DIM, FG_DIM, FG_DIM); write("enter password:", { x: 20, y: h / 2 - 24, size: 2, font: "font_1" }); // Password field — visible text with blinking cursor const cursor = (wifiCursorBlink % 60) < 35 ? "|" : ""; const pwDisplay = wifiPassword + cursor; ink(FG, FG, FG); // Background box for password field ink(dark ? 25 : 235, dark ? 25 : 235, dark ? 30 : 240); box(18, h / 2 - 6, w - 36, 18, true); ink(dark ? 70 : 180, dark ? 70 : 180, dark ? 80 : 190); box(18, h / 2 - 6, w - 36, 18, "outline"); // Password text (visible!) ink(FG, FG, FG); write(pwDisplay, { x: 22, y: h / 2 - 2, size: 1, font: "font_1" }); // Instructions ink(FG_MUTED, FG_MUTED, FG_MUTED); write("Enter: connect Esc: cancel", { x: 20, y: h / 2 + 22, size: 1, font: "font_1" }); // Show connection status if connecting if (wifi.state === 3) { // WIFI_STATE_CONNECTING ink(200, 200, 80); write("connecting...", { x: 20, y: h / 2 + 40, size: 1, font: "font_1" }); } else if (wifi.state === 5) { // WIFI_STATE_FAILED ink(220, 80, 80); write("failed: " + (wifi.status || "?"), { x: 20, y: h / 2 + 40, size: 1, font: "font_1" }); } } // WiFi fullscreen network chooser else if (activeScreen === "wifi" && wifi) { // Fullscreen dark overlay ink(dark ? 10 : 240, dark ? 10 : 240, dark ? 15 : 245, 250); box(0, 0, w, h, true); // Title ink(FG, FG, FG); write("WiFi Networks", { x: 20, y: 12, size: 2, font: "matrix" }); // Status + interface info ink(FG_DIM, FG_DIM, FG_DIM); const wifiStatusStr = (wifi.status || "scanning...") + (wifi.iface ? " [" + wifi.iface + "]" : ""); write(wifiStatusStr, { x: 20, y: 30, size: 1, font: "font_1" }); // Build merged network list: scanned first (deduplicated), then saved/preset not in scan const rawNets = wifi.networks || []; // Deduplicate by SSID — keep the entry with strongest signal const ssidBest = new Map(); for (const n of rawNets) { if (!n.ssid) continue; const prev = ssidBest.get(n.ssid); if (!prev || n.signal > prev.signal) ssidBest.set(n.ssid, n); } const scannedNets = [...ssidBest.values()].sort((a, b) => b.signal - a.signal); const scannedSSIDs = new Set(scannedNets.map((n) => n.ssid)); const allSaved = [ { ssid: AC_SSID, pass: AC_PASS }, ...savedCreds.filter((c) => c.ssid !== AC_SSID), ]; // Saved networks not currently visible in scan const offlineSaved = allSaved.filter((c) => !scannedSSIDs.has(c.ssid)); const rowH = 16; const listY = 44; const totalRows = scannedNets.length + (offlineSaved.length > 0 ? 1 + offlineSaved.length : 0); const maxRows = Math.min(totalRows, Math.floor((h - listY - 30) / rowH)); // Store merged list for touch handler globalThis.__wifiMergedList = []; let row = 0; // Scanned networks for (let i = 0; i < scannedNets.length && row < maxRows; i++, row++) { const net = scannedNets[i]; const ry = listY + row * rowH; const isSaved = allSaved.find((c) => c.ssid === net.ssid); const isSelected = row === wifiSelectedIdx; if (isSelected) { ink(dark ? 40 : 210, dark ? 55 : 215, dark ? 80 : 230); box(10, ry, w - 20, rowH, true); } // Signal bars const bars = net.signal > -50 ? 4 : net.signal > -60 ? 3 : net.signal > -70 ? 2 : 1; for (let b = 0; b < 4; b++) { if (b < bars) ink(80, 200, 80); else ink(dark ? 40 : 220, dark ? 40 : 220, dark ? 45 : 225); box(16 + b * 4, ry + 10 - (b + 1) * 2, 3, (b + 1) * 2, true); } // SSID name (green tint if saved/known) if (isSaved) ink(100, 220, 100); else ink(FG, FG, FG); const ssidDisplay = net.ssid.length > 26 ? net.ssid.slice(0, 25) + "~" : net.ssid; write(ssidDisplay, { x: 36, y: ry + 2, size: 1, font: "font_1" }); // Right side: "tap to connect" if selected, else saved badge / lock icon if (isSelected) { ink(80, 180, 255); write("connect", { x: w - 52, y: ry + 2, size: 1, font: "font_1" }); } else if (isSaved) { ink(60, 160, 60); write("saved", { x: w - 40, y: ry + 2, size: 1, font: "font_1" }); } else if (net.encrypted) { ink(FG_MUTED, FG_MUTED, FG_MUTED); write("*", { x: w - 20, y: ry + 2, size: 1, font: "font_1" }); } globalThis.__wifiMergedList.push({ type: "scan", idx: i, ssid: net.ssid, encrypted: net.encrypted }); } // Saved/preset networks section (not in scan) if (offlineSaved.length > 0 && row < maxRows) { const sepY = listY + row * rowH; ink(FG_MUTED, FG_MUTED, FG_MUTED); write("-- saved (not in range) --", { x: 20, y: sepY + 2, size: 1, font: "font_1" }); globalThis.__wifiMergedList.push({ type: "separator" }); row++; for (let i = 0; i < offlineSaved.length && row < maxRows; i++, row++) { const cred = offlineSaved[i]; const ry = listY + row * rowH; const isPreset = cred.ssid === AC_SSID; const isSelSaved = row === wifiSelectedIdx; if (isSelSaved) { ink(dark ? 35 : 225, dark ? 40 : 230, dark ? 55 : 240); box(10, ry, w - 20, rowH, true); } // No signal bars — show dim placeholder for (let b = 0; b < 4; b++) { ink(dark ? 30 : 230, dark ? 30 : 230, dark ? 35 : 235); box(16 + b * 4, ry + 10 - (b + 1) * 2, 3, (b + 1) * 2, true); } // SSID in dim color ink(dark ? 120 : 160, dark ? 120 : 160, dark ? 130 : 170); write(cred.ssid, { x: 36, y: ry + 2, size: 1, font: "font_1" }); // Right side: "connect" if selected, else label if (isSelSaved) { ink(80, 180, 255); write("connect", { x: w - 52, y: ry + 2, size: 1, font: "font_1" }); } else if (isPreset) { ink(80, 120, 160); write("preset", { x: w - 44, y: ry + 2, size: 1, font: "font_1" }); } else { ink(60, 140, 60); write("saved", { x: w - 40, y: ry + 2, size: 1, font: "font_1" }); } globalThis.__wifiMergedList.push({ type: "saved", ssid: cred.ssid, pass: cred.pass }); } } // Connected IP + latest AC message at bottom if (wifi.connected && wifi.ip) { ink(80, 200, 80); const sshLabel = system.sshStarted ? " ssh root@" + wifi.ip : ""; write("connected: " + wifi.ip + sshLabel, { x: 20, y: h - 26, size: 1, font: "font_1" }); if (acMsg) { ink(FG_DIM, FG_DIM, FG_DIM); const preview = (acMsg.from + ": " + acMsg.text).slice(0, 52); write(preview, { x: 20, y: h - 16, size: 1, font: "font_1" }); } else if (system.ws?.connecting) { ink(FG_MUTED, FG_MUTED, FG_MUTED); write("connecting to chat...", { x: 20, y: h - 16, size: 1, font: "font_1" }); } else if (system.ws?.connected) { ink(FG_MUTED, FG_MUTED, FG_MUTED); write("loading chat...", { x: 20, y: h - 16, size: 1, font: "font_1" }); } } // Instructions ink(FG_MUTED, FG_MUTED, FG_MUTED); write("Esc: close", { x: w - 60, y: h - 26, size: 1, font: "font_1" }); // Rescan timer — only when not connected (avoid disrupting active connection) if (frame % 600 === 0 && activeScreen === "wifi" && !wifi.connected) { wifi.scan(); } } // Chat preview removed from bottom — shown only in status bar at top // Fade trails Object.keys(trail).forEach((key) => { if (!sounds[key]) { trail[key].brightness *= 0.92; if (trail[key].brightness < 0.02) delete trail[key]; } }); // 🧚 Fairy co-presence — send cursor + paint received fireflies if (system.udp?.connected) { maybeSendUdpMidiHeartbeat(system); // Send current cursor/touch position as fairy point (~60Hz, throttled by UDP thread) if (hoverX >= 0 && hoverY >= 0) { system.udp.sendFairy(hoverX / w, hoverY / h); } // Paint received fairies as glowing dots const fairies = system.udp.fairies; if (fairies && fairies.length > 0) { for (let i = 0; i < fairies.length; i++) { const fx = Math.round(fairies[i].x * w); const fy = Math.round(fairies[i].y * h); // Outer glow ink(255, 200, 80, 40); box(fx - 3, fy - 3, 7, 7, true); // Inner bright dot ink(255, 240, 120, 180); box(fx - 1, fy - 1, 3, 3, true); // Center pixel ink(255, 255, 200); box(fx, fy, 1, 1, true); } } } // HDMI output: blend active note colors → solid fill on secondary display if (system.hasHdmi) { const activeKeys = Object.keys(sounds); if (activeKeys.length > 0) { let tr = 0, tg = 0, tb = 0; for (const k of activeKeys) { const s = sounds[k]; if (s && s.note) { const c = noteColor(s.note); tr += c[0]; tg += c[1]; tb += c[2]; } } const n = activeKeys.length; system.hdmi(Math.round(tr / n), Math.round(tg / n), Math.round(tb / n)); } else { system.hdmi(0, 0, 0); } } // Help panel overlay (Meta/Win key) — drawn last so it sits on top of everything. // // Design goals: // - Color-coded by category (notes, drums, wave, deck, hold, tempo, sample, fx, system) // - Multi-column layout that adapts to screen width (1, 2, or 3 columns) // - Panel height auto-fits content AND hard-clamps to screen height so it // never bleeds off the bottom // - Uses available width rather than being capped at 280 // - Bindings reflect current code (space = reverse loop pedal, F1–F4 direct // deck control, F9 metronome, F10/F11 hold, F12 recital) if (helpPanel) { const dark = isDark(); const margin = 10; const padX = 8, padY = 8; const lineH = 10; const titleH = 14; const footerH = 11; // Category [name, color (RGB), entries[[key, desc], ...]] // Color = text tint for the key column on that row. Category header is // rendered in the same color at full saturation. const categories = [ ["NOTES", [120, 200, 255], [ ["a\u2013l ; '", "play notes"], ["0\u20139", "GM instrument"], [", .", "octave \u2212 / +"], ["/", "attack (+shift decay)"], ["\u2190 \u2192", "select L/R side"], ["\u2191 \u2193", "volume up/down"], ["ctrl/alt", "maj / min chord"], ["shift", "accent + linger"], ]], ["DRUMS", [255, 140, 90], [ ["pgup/pgdn", "kit: off\u2192drums\u2192war"], ["space", "reverse loop"], ]], ["WAVE", [220, 140, 255], [ ["tab", "cycle wave"], ["F12 \u2605", "recital mode"], ]], ["DECK", [120, 230, 150], [ ["F1", "play / pause"], ["F2", "next track"], ["F3", "prev track"], ["F4", "usb rescan"], ["[ / `", "speed \u2212 / reset"], ["drag", "scratch"], ]], ["HOLD", [255, 220, 120], [ ["enter", "add holds"], ["backspace", "clear holds"], ]], ["TEMPO", [255, 180, 100], [ ["F9", "metronome"], ["- / =", "bpm \u2212 / +"], ]], ["SAMPLE", [255, 150, 210], [ ["home", "record global"], ["end", "arm per-key"], ["delete", "clear samples"], ]], ["FX", [130, 230, 220], [ ["\\", "trackpad fx"], ]], ["TAPE", [255, 120, 120], [ ["prtsc/ins", "start/stop tape"], ]], ["SYSTEM", [190, 190, 205], [ ["meta", "toggle help"], ["esc\u00d73", "exit to prompt"], ]], ]; // Build flat row list: each category contributes 1 header row + N item rows. // Rows are { type: "header"|"item", ... } so the layout pass can handle both. const rows = []; for (const [cat, color, items] of categories) { rows.push({ type: "header", cat, color }); for (const [k, desc] of items) { rows.push({ type: "item", k, desc, color }); } } // Column width: key col ~54px + gap + desc col ~110px = ~172px per column. const COL_W = 172; const availW = Math.max(160, w - margin * 2); // Auto-pick 1/2/3 columns based on available width. const numCols = Math.max(1, Math.min(3, Math.floor(availW / COL_W))); const rowsPerCol = Math.ceil(rows.length / numCols); // Desired panel dimensions. const desiredW = Math.min(availW, COL_W * numCols + padX * 2); const desiredH = titleH + padY * 2 + rowsPerCol * lineH + footerH + 4; // Hard clamp to screen so the panel NEVER bleeds off the bottom. const maxH = h - margin * 2; const panelW = desiredW; const panelH = Math.min(desiredH, maxH); // If clamped, we may need to shrink rowsPerCol to what actually fits. const gridH = panelH - titleH - padY * 2 - footerH - 4; const fitRowsPerCol = Math.max(1, Math.floor(gridH / lineH)); const actualRowsPerCol = Math.min(rowsPerCol, fitRowsPerCol); const px = Math.floor((w - panelW) / 2); const py = Math.floor((h - panelH) / 2); // Background: soft drop shadow + panel fill + subtle outline. ink(0, 0, 0, 160); box(px + 3, py + 3, panelW, panelH, true); ink(dark ? 14 : 248, dark ? 14 : 248, dark ? 22 : 252); box(px, py, panelW, panelH, true); ink(dark ? 90 : 120, dark ? 90 : 120, dark ? 115 : 150); box(px, py, panelW, panelH, "outline"); // Title bar ink(dark ? 235 : 30, dark ? 235 : 30, dark ? 245 : 50); write("notepat shortcuts", { x: px + padX, y: py + padY, size: 1, font: "font_1" }); ink(dark ? 80 : 170, dark ? 80 : 170, dark ? 105 : 190); line(px + padX, py + padY + 10, px + panelW - padX, py + padY + 10); // Grid layout: rows flow top→bottom within a column, then wrap to next column. const gridY = py + padY + titleH; const colGap = Math.floor((panelW - padX * 2) / numCols); const descOffset = 54; // x offset within column for description text for (let i = 0; i < rows.length; i++) { const col = Math.floor(i / actualRowsPerCol); if (col >= numCols) break; // overflow — rare, only if clamped const rowInCol = i % actualRowsPerCol; const rx = px + padX + col * colGap; const ry = gridY + rowInCol * lineH; const r = rows[i]; if (r.type === "header") { // Full-saturation category label const [cr, cg, cb] = r.color; ink(cr, cg, cb); write(r.cat, { x: rx, y: ry, size: 1, font: "font_1" }); // Thin underline in the same color, muted ink(Math.floor(cr * 0.55), Math.floor(cg * 0.55), Math.floor(cb * 0.55)); line(rx, ry + 8, rx + colGap - 8, ry + 8); } else { // Key: category color, slightly brighter in dark mode for legibility const [cr, cg, cb] = r.color; if (dark) { ink(Math.min(255, cr + 15), Math.min(255, cg + 15), Math.min(255, cb + 15)); } else { // Darken for light mode contrast ink(Math.floor(cr * 0.55), Math.floor(cg * 0.55), Math.floor(cb * 0.55)); } write(r.k, { x: rx + 6, y: ry, size: 1, font: "font_1" }); // Description: muted gray that reads well on both themes. // Clamp to the remaining column width so long descriptions don't // bleed past the panel edge. font_1 = 6px/char. ink(dark ? 160 : 85, dark ? 165 : 85, dark ? 180 : 100); const descW = Math.max(0, colGap - descOffset - 10); const maxChars = Math.floor(descW / 6); let descText = r.desc; if (descText.length > maxChars && maxChars > 2) { descText = descText.slice(0, maxChars - 1) + "."; } write(descText, { x: rx + 6 + descOffset, y: ry, size: 1, font: "font_1" }); } } // Footer hint — always visible, always inside the panel ink(dark ? 100 : 140, dark ? 100 : 140, dark ? 125 : 160); write("meta to close", { x: px + padX, y: py + panelH - padY - 2, size: 1, font: "font_1" }); } // GLOBAL VOICE CHOOSER overlay — the single horizontal strip of all 128 GM // voices then the 8 AC wavetypes. Drawn here (near the very end of paint) so // it floats above the grids/HUD; visible for a beat after each Left/Right // step (or any voice change), then fades. drawVoiceChooser eases its own // scroll each call, so we keep stepping the animation even while fading. if (chooserVisible || frame < chooserUntil || Math.abs(voiceIndex - voiceScroll) > 0.01) { drawVoiceChooser(ink, box, write, w, h, isDark()); } // Shift = GLOBAL alternate-sound mode. While held, draw an outline // around the whole screen so the active state is obvious at a glance // regardless of what instrument/kit is loaded. The alternate sound // itself is emitted downstream by the kit-specific code (e.g. harp → // short pluck, whistle → messy blow, all instruments → accent boost). // Color pulses faintly between two amber tones so it feels alive // without being visually noisy. if (shiftHeld) { const pulse = (frame & 7) < 4 ? 220 : 180; ink(255, pulse, 60, 220); const W = screen.width, H = screen.height; box(0, 0, W, 2, true); // top box(0, H - 2, W, 2, true); // bottom box(0, 0, 2, H, true); // left box(W - 2, 0, 2, H, true); // right } } function sim({ pressures, sound }) { // Flush any due setTimeout callbacks (polyfill for missing QuickJS timer) __tickPendingTimeouts(); // Auto-stop recording at max duration if (recording && (Date.now() - recStartTime) / 1000 >= MAX_REC_SECS) { stopSampleRecording(sound, "max-duration"); } // Waveform cursor behavior: // // spaceHeld: drive offset directly from elapsed-since-press. The // reverse-replay voice is reading the captured buffer // backwards at 1× rate, so offset = elapsed guarantees // the visual needle sits on exactly the sample being // played. Cap at MAX. // // released: snap cursor back to 0 immediately. An earlier iteration // eased the offset back over ~20 frames, but the audio // replay voice stops on release — those ~300 ms of // sweeping visual had no sound behind them, reading as // "dead silent time" at the end of every reverse gesture. // Snapping eliminates the audio/visual mismatch; the // press→reverse→release feels like a clean in-and-out // cut with nothing hanging off the back of it. if (spaceHeld && spacePressStartMs > 0) { const elapsedSec = (Date.now() - spacePressStartMs) / 1000; waveViewOffsetSec = Math.min(WAVE_VIEW_MAX_OFFSET_SEC, elapsedSec); waveDriftSpeed = -1.0; // cosmetic (only needle-color uses it) } else { waveViewOffsetSec = 0; waveDriftSpeed = 1.0; } // Update dark/light mode via global theme (every ~5 seconds) if (frame % 300 === 0) { const wasDark = dark; __theme.update(); dark = __theme.dark; if (dark !== wasDark) bgTarget = dark ? [20, 20, 25] : [240, 238, 232]; } // === DJ DECK MAINTENANCE === // Hot-plug check every ~3s while active, or ~15s when idle. const djUsbCheckFrames = djUsbConnected ? DJ_USB_CHECK_ACTIVE_FRAMES : DJ_USB_CHECK_IDLE_FRAMES; const mountPending = !!systemAPI?.mountMusicPending; const nowMounted = !!systemAPI?.mountMusicMounted; if (!mountPending && frame - djLastUsbCheckFrame > djUsbCheckFrames) { djLastUsbCheckFrame = frame; systemAPI?.mountMusic?.(); } if (nowMounted && !djUsbConnected) { djUsbConnected = true; djMounted = true; sound?.speak?.("USB dj on"); djScan(systemAPI, null); if (djFiles.length > 0) { djTrackIdx = 0; djLoadTrack(sound); } } else if (!nowMounted && djUsbConnected && !mountPending) { djUsbConnected = false; const dk = sound?.deck; if (dk?.decks?.[0]?.playing) dk.pause(0); djFiles = []; sound?.speak?.("USB dj off"); djMsg("USB removed"); } // Auto-advance when the current track finishes const __dkD = sound?.deck?.decks?.[0]; if (__dkD?.loaded && !__dkD.playing && __dkD.duration > 0 && __dkD.position >= __dkD.duration - 0.1 && !djDragging) { djTrackIdx++; djLoadTrack(sound); } // Spin the little deck indicator even when nothing else is happening if (__dkD?.playing && !djDragging) djAngle += (__dkD.speed || 1) * 0.05; // Continuously update synth volumes from analog key pressure AND from // the current per-side master volume so dragging the L/R master sliders // live-adjusts sustained notes on that side. // // Pop mitigation: raw NuPhy pressure samples can jump between polls // (HE keyboards sample at driver-specific rates and report discrete // ADC steps). Writing those jumps straight to synth.update() caused // audible clicks on sustained tones like `sine`. Instead we: // // 1. Capture velocity ONCE at key-down (entry.velocity) — sets the // note's baseline loudness based on press impact. // 2. Smooth the pressure-to-volume mapping with a one-pole lowpass // (α ≈ 0.2, ~80ms time constant) so per-frame jumps glide // instead of snapping. Keeps the expressive wobble that real // pressure-sensitive play needs, drops the pops. // 3. Blend: `vol = 0.55·velocity + 0.45·smoothed_pressure` — the // initial hit energy dominates, live pressure just modulates. // 4. Only push synth.update() when the new smoothed value has // moved meaningfully (>0.5%) — avoids flooding the audio thread. const PRESSURE_ALPHA = 0.20; for (const key of Object.keys(sounds)) { const entry = sounds[key]; if (!entry?.synth) continue; const master = entry.gridOffset === 1 ? rightMasterVol : leftMasterVol; const p = pressures?.[key]; // 0.0-1.0 analog pressure, undefined if not analog if (p !== undefined) { // Smoothed pressure (lowpassed to remove ADC-step pops) const prevSmooth = entry.__pSmooth ?? p; const pSmooth = prevSmooth + (p - prevSmooth) * PRESSURE_ALPHA; entry.__pSmooth = pSmooth; // Velocity captured at key-down (falls back to baseVol default). const velocity = entry.velocity ?? 1.0; // 55% velocity baseline + 45% smoothed-pressure wobble, scaled by // master. Full-press at velocity=1 + master=1 = 1.0 peak. const vol = (velocity * 0.55 + pSmooth * 0.45) * master; if (entry.__lastVol === undefined || Math.abs(entry.__lastVol - vol) > 0.005) { entry.synth.update({ volume: vol }); entry.__lastVol = vol; } if (trail[key]) trail[key].brightness = pSmooth; } else { // Digital key — only react to master volume changes, not pressure. if (entry.__lastMaster !== master) { const baseVol = entry.baseVol ?? 0.7; entry.synth.update({ volume: baseVol * master }); entry.__lastMaster = master; } } } updateActivePercussionVoices(false); // Prune expired linger bookkeeping (the engine fades the actual voices). if (lingerVoices.length) lingerVoices = lingerVoices.filter((l) => l.until > frame); } function leave() { stopSampleRecording(soundAPI, "leave"); // Reset all FX before shutdown echoMix = 0; bitcrushMix = 0; pitchShift = 0; lastAppliedPitch = 0; fxMix = 1; masterVolMix = 0.5; // unity (slider 0..1 → audio 0..2) driveMix = 0; wobbleMix = 0; trackpadFX = false; soundAPI?.room?.setMix?.(0); soundAPI?.glitch?.setMix?.(0); soundAPI?.fx?.setMix?.(1); soundAPI?.volume?.setMix?.(1); // unity soundAPI?.drive?.setMix?.(0); // clean soundAPI?.wobble?.setMix?.(0); // clean stopAllSounds(soundAPI, systemAPI, 0.02); } export { boot, act, paint, sim, leave };