commit 8ca3b27483ff0e40ff880417b0360169b5dd7012 Author: pavel Date: Mon Mar 2 20:09:22 2026 +0100 INIT diff --git a/app.js b/app.js new file mode 100644 index 0000000..144296c --- /dev/null +++ b/app.js @@ -0,0 +1,1188 @@ +const KEY_ROWS = [ + ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], + ["a", "s", "d", "f", "g", "h", "j", "k", "l"], + ["z", "x", "c", "v", "b", "n", "m"], +]; + +const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +const TEXTURE_LABELS = { + piano: "Piano", + warm: "Warm Keys", + glass: "Glass Pad", + pluck: "Pluck", + organ: "Tape Organ", +}; + +const SCALE_DEFINITIONS = { + major: { label: "Major", intervals: [0, 2, 4, 5, 7, 9, 11] }, + minor: { label: "Minor", intervals: [0, 2, 3, 5, 7, 8, 10] }, + dorian: { label: "Dorian", intervals: [0, 2, 3, 5, 7, 9, 10] }, + pentatonic: { label: "Pentatonic", intervals: [0, 3, 5, 7, 10] }, + chromatic: { label: "Chromatic", intervals: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }, +}; + +const LAYOUT_LABELS = { + cascade: "Cascade", + linear: "Linear", + grid: "Grid", +}; + +const SEQUENCER_LANES = [ + { id: "kick", label: "Kick", hint: "Low thump", color: "kick" }, + { id: "snare", label: "Snare", hint: "Backbeat", color: "snare" }, + { id: "hat", label: "Hat", hint: "Tick", color: "hat" }, + { id: "clap", label: "Clap", hint: "Accent", color: "clap" }, +]; + +const dom = { + audioToggle: document.querySelector("#audio-toggle"), + textureSelect: document.querySelector("#texture-select"), + scaleSelect: document.querySelector("#scale-select"), + layoutSelect: document.querySelector("#layout-select"), + octaveDisplay: document.querySelector("#octave-display"), + octaveDown: document.querySelector("#octave-down"), + octaveUp: document.querySelector("#octave-up"), + metronomeToggle: document.querySelector("#metronome-toggle"), + metronomeLight: document.querySelector("#metronome-light"), + tempoSlider: document.querySelector("#tempo-slider"), + tempoDisplay: document.querySelector("#tempo-display"), + sequencerToggle: document.querySelector("#sequencer-toggle"), + sequencerClear: document.querySelector("#sequencer-clear"), + sequencerRandom: document.querySelector("#sequencer-random"), + sequencerGrid: document.querySelector("#sequencer-grid"), + noteDisplay: document.querySelector("#note-display"), + rows: { + top: document.querySelector("#row-top"), + middle: document.querySelector("#row-middle"), + bottom: document.querySelector("#row-bottom"), + }, +}; + +let audioContext = null; +let masterGain = null; +let masterFilter = null; +let convolver = null; +let noiseBuffer = null; + +let currentTexture = dom.textureSelect.value; +let currentScale = dom.scaleSelect.value; +let currentLayout = dom.layoutSelect.value; +let currentOctave = 4; +let chordOverlayRootKey = null; +let activeKeys = new Set(); +let activeVoices = new Map(); + +let transportTimer = null; +let nextTransportStepTime = 0; +let transportStep = 0; + +const transportState = { + tempo: Number(dom.tempoSlider.value), + beatsPerBar: 4, + lookaheadMs: 25, + scheduleAheadTime: 0.14, +}; + +const metronomeState = { + enabled: false, +}; + +const sequencerState = { + enabled: false, + steps: 16, + currentStep: -1, + patterns: createDefaultPatterns(), +}; + +const KEY_POSITIONS = Object.fromEntries( + KEY_ROWS.flatMap((row, rowIndex) => row.map((key, index) => [key, { rowIndex, index }])) +); +const PLAYABLE_KEYS = new Set(KEY_ROWS.flat()); + +function createDefaultPatterns() { + return { + kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0], + snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], + hat: [1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0], + clap: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + }; +} + +function midiToFrequency(midiNote) { + return 440 * (2 ** ((midiNote - 69) / 12)); +} + +function noteNameFromMidi(midiNote) { + const noteName = NOTE_NAMES[((midiNote % 12) + 12) % 12]; + const octave = Math.floor(midiNote / 12) - 1; + return `${noteName}${octave}`; +} + +function getCurrentScaleDefinition() { + return SCALE_DEFINITIONS[currentScale]; +} + +function scaleDegreeToSemitone(degree) { + const { intervals } = getCurrentScaleDefinition(); + const length = intervals.length; + const octave = Math.floor(degree / length); + const index = ((degree % length) + length) % length; + return octave * 12 + intervals[index]; +} + +function getDegreeForKey(key) { + const position = KEY_POSITIONS[key]; + if (!position) { + return 0; + } + + if (currentLayout === "cascade") { + const scaleLength = getCurrentScaleDefinition().intervals.length; + if (position.rowIndex === 0) { + return scaleLength + position.index; + } + if (position.rowIndex === 1) { + return position.index; + } + return -scaleLength + position.index; + } + + if (currentLayout === "linear") { + const bottomLength = KEY_ROWS[2].length; + const middleLength = KEY_ROWS[1].length; + + if (position.rowIndex === 2) { + return position.index; + } + if (position.rowIndex === 1) { + return bottomLength + position.index; + } + return bottomLength + middleLength + position.index; + } + + if (position.rowIndex === 0) { + return 4 + position.index; + } + if (position.rowIndex === 1) { + return position.index; + } + return -4 + position.index; +} + +function normalizePitchClass(value) { + return ((value % 12) + 12) % 12; +} + +function describeChord(intervals) { + const third = normalizePitchClass(intervals[1]); + const fifth = normalizePitchClass(intervals[2]); + + if (third === 4 && fifth === 7) { + return "maj"; + } + if (third === 3 && fifth === 7) { + return "min"; + } + if (third === 3 && fifth === 6) { + return "dim"; + } + if (third === 4 && fifth === 8) { + return "aug"; + } + if (third === 2 && fifth === 7) { + return "sus2"; + } + if (third === 5 && fifth === 7) { + return "sus4"; + } + + return "stack"; +} + +function getTriadForKey(key) { + const rootMidi = noteForKey(key); + let intervals; + + if (currentScale === "chromatic") { + intervals = [0, 4, 7]; + } else { + const rootDegree = getDegreeForKey(key); + intervals = [ + 0, + scaleDegreeToSemitone(rootDegree + 2) - scaleDegreeToSemitone(rootDegree), + scaleDegreeToSemitone(rootDegree + 4) - scaleDegreeToSemitone(rootDegree), + ]; + } + + return { + intervals, + quality: describeChord(intervals), + notes: [ + { role: "R", midi: rootMidi }, + { role: "3", midi: rootMidi + intervals[1] }, + { role: "5", midi: rootMidi + intervals[2] }, + ], + }; +} + +function findKeyByMidi(midiNote) { + for (const key of PLAYABLE_KEYS) { + if (noteForKey(key) === midiNote) { + return key; + } + } + + return null; +} + +function getDefaultChordOverlayKey() { + let bestKey = null; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const key of PLAYABLE_KEYS) { + const distance = Math.abs(getDegreeForKey(key)); + if (distance < bestDistance) { + bestDistance = distance; + bestKey = key; + } + } + + return bestKey; +} + +function updateChordOverlay(rootKey = chordOverlayRootKey) { + const resolvedRootKey = rootKey && PLAYABLE_KEYS.has(rootKey) ? rootKey : getDefaultChordOverlayKey(); + chordOverlayRootKey = resolvedRootKey; + + document.querySelectorAll(".key").forEach((button) => { + button.classList.remove("is-chord-root", "is-chord-tone"); + button.querySelector(".key__shape-role").textContent = ""; + button.querySelector(".key__shape-name").textContent = ""; + }); + + if (!resolvedRootKey) { + return; + } + + const triad = getTriadForKey(resolvedRootKey); + triad.notes.forEach((note, index) => { + const targetKey = index === 0 ? resolvedRootKey : findKeyByMidi(note.midi); + if (!targetKey) { + return; + } + + const button = document.querySelector(`.key[data-key="${targetKey}"]`); + if (!button) { + return; + } + + button.classList.add(index === 0 ? "is-chord-root" : "is-chord-tone"); + button.querySelector(".key__shape-role").textContent = note.role; + if (index === 0) { + button.querySelector(".key__shape-name").textContent = triad.quality; + } + }); +} + +function getIdleStatus() { + if (sequencerState.enabled && metronomeState.enabled) { + return `Beat + metronome | ${transportState.tempo} BPM`; + } + + if (sequencerState.enabled) { + return `Beat sequencer on | ${transportState.tempo} BPM`; + } + + if (metronomeState.enabled) { + return `Metronome on | ${transportState.tempo} BPM`; + } + + return "Press any mapped letter"; +} + +function updateStatus(text) { + dom.noteDisplay.textContent = text; +} + +function resetStatus() { + if (activeKeys.size === 0) { + updateStatus(getIdleStatus()); + } +} + +function updateOctaveDisplay() { + dom.octaveDisplay.textContent = currentOctave; +} + +function updateTempoDisplay() { + dom.tempoDisplay.textContent = `${transportState.tempo} BPM`; +} + +function updateMetronomeButton() { + dom.metronomeToggle.textContent = metronomeState.enabled ? "Stop" : "Start"; + dom.metronomeToggle.classList.toggle("is-running", metronomeState.enabled); + dom.metronomeToggle.setAttribute("aria-pressed", String(metronomeState.enabled)); +} + +function updateSequencerButton() { + dom.sequencerToggle.textContent = sequencerState.enabled ? "Stop beat" : "Play beat"; + dom.sequencerToggle.classList.toggle("is-running", sequencerState.enabled); + dom.sequencerToggle.setAttribute("aria-pressed", String(sequencerState.enabled)); +} + +function createImpulseResponse(context, duration = 2.2, decay = 2.4) { + const length = Math.floor(context.sampleRate * duration); + const impulse = context.createBuffer(2, length, context.sampleRate); + + for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) { + const data = impulse.getChannelData(channel); + for (let i = 0; i < length; i += 1) { + const envelope = ((length - i) / length) ** decay; + data[i] = (Math.random() * 2 - 1) * envelope; + } + } + + return impulse; +} + +function createNoiseBuffer(context, duration = 1) { + const length = Math.floor(context.sampleRate * duration); + const buffer = context.createBuffer(1, length, context.sampleRate); + const data = buffer.getChannelData(0); + + for (let i = 0; i < length; i += 1) { + data[i] = Math.random() * 2 - 1; + } + + return buffer; +} + +async function ensureAudio() { + if (!audioContext) { + audioContext = new AudioContext(); + + masterGain = audioContext.createGain(); + masterGain.gain.value = 0.72; + + masterFilter = audioContext.createBiquadFilter(); + masterFilter.type = "lowpass"; + masterFilter.frequency.value = 11000; + masterFilter.Q.value = 0.7; + + convolver = audioContext.createConvolver(); + convolver.buffer = createImpulseResponse(audioContext); + + noiseBuffer = createNoiseBuffer(audioContext); + + convolver.connect(masterGain); + masterFilter.connect(masterGain); + masterGain.connect(audioContext.destination); + } + + if (audioContext.state !== "running") { + await audioContext.resume(); + } + + dom.audioToggle.textContent = "Audio on"; + dom.audioToggle.classList.add("power--armed"); +} + +function buildKeyMap() { + Object.values(dom.rows).forEach((rowElement) => { + rowElement.textContent = ""; + }); + + for (const [rowIndex, row] of KEY_ROWS.entries()) { + const rowName = rowIndex === 0 ? "top" : rowIndex === 1 ? "middle" : "bottom"; + const rowElement = dom.rows[rowName]; + + row.forEach((key, index) => { + const button = document.createElement("button"); + const midi = noteForKey(key); + + button.type = "button"; + button.className = "key"; + button.dataset.key = key; + button.setAttribute("aria-label", `Play ${noteNameFromMidi(midi)} with key ${key.toUpperCase()}`); + button.innerHTML = ` + ${key.toUpperCase()} + ${noteNameFromMidi(midi)} + + + ${index + 1} + `; + + button.addEventListener("pointerdown", async (event) => { + event.preventDefault(); + updateChordOverlay(key); + await ensureAudio(); + triggerKeyDown(key); + }); + + button.addEventListener("pointerenter", () => updateChordOverlay(key)); + button.addEventListener("focus", () => updateChordOverlay(key)); + button.addEventListener("pointerup", () => triggerKeyUp(key)); + button.addEventListener("pointercancel", () => triggerKeyUp(key)); + button.addEventListener("pointerleave", () => triggerKeyUp(key)); + button.addEventListener("lostpointercapture", () => triggerKeyUp(key)); + + rowElement.append(button); + }); + } + + updateChordOverlay(chordOverlayRootKey); +} + +function buildSequencerGrid() { + dom.sequencerGrid.textContent = ""; + + const inner = document.createElement("div"); + inner.className = "sequencer-grid__inner"; + + const corner = document.createElement("div"); + corner.className = "sequencer-grid__corner"; + inner.append(corner); + + for (let step = 0; step < sequencerState.steps; step += 1) { + const label = document.createElement("div"); + label.className = "sequencer-grid__step-label"; + if (step % 4 === 0) { + label.classList.add("is-accent"); + } + label.textContent = `${step + 1}`; + inner.append(label); + } + + SEQUENCER_LANES.forEach((lane) => { + const laneLabel = document.createElement("div"); + laneLabel.className = "sequencer-lane__name"; + laneLabel.innerHTML = ` + + + ${lane.label} + ${lane.hint} + + `; + inner.append(laneLabel); + + for (let step = 0; step < sequencerState.steps; step += 1) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "sequencer-step"; + button.dataset.lane = lane.id; + button.dataset.step = `${step}`; + button.setAttribute("aria-label", `${lane.label} step ${step + 1}`); + + if (step % 4 === 0) { + button.classList.add("is-accent"); + } + + button.addEventListener("click", async () => { + toggleSequencerStep(lane.id, step); + await previewDrum(lane.id); + }); + + inner.append(button); + } + }); + + dom.sequencerGrid.append(inner); + updateSequencerPadStates(); +} + +function updateSequencerPadStates() { + document.querySelectorAll(".sequencer-step").forEach((button) => { + const lane = button.dataset.lane; + const step = Number(button.dataset.step); + const isActive = Boolean(sequencerState.patterns[lane][step]); + const isCurrent = sequencerState.currentStep === step && sequencerState.enabled; + + button.classList.toggle("is-active", isActive); + button.classList.toggle("is-current", isCurrent); + button.setAttribute("aria-pressed", String(isActive)); + }); +} + +function setSequencerPlayhead(step) { + sequencerState.currentStep = sequencerState.enabled ? step : -1; + updateSequencerPadStates(); +} + +function clearSequencerPlayhead() { + sequencerState.currentStep = -1; + updateSequencerPadStates(); +} + +function toggleSequencerStep(laneId, step) { + const currentValue = Boolean(sequencerState.patterns[laneId][step]); + sequencerState.patterns[laneId][step] = currentValue ? 0 : 1; + updateSequencerPadStates(); +} + +function clearSequencerPattern() { + SEQUENCER_LANES.forEach((lane) => { + sequencerState.patterns[lane.id] = new Array(sequencerState.steps).fill(0); + }); + updateSequencerPadStates(); + resetStatus(); +} + +function randomizeSequencerPattern() { + const densities = { + kick: 0.34, + snare: 0.2, + hat: 0.6, + clap: 0.16, + }; + + SEQUENCER_LANES.forEach((lane) => { + sequencerState.patterns[lane.id] = Array.from({ length: sequencerState.steps }, (_, step) => { + if (lane.id === "snare" && (step === 4 || step === 12)) { + return 1; + } + + if (lane.id === "kick" && step === 0) { + return 1; + } + + return Math.random() < densities[lane.id] ? 1 : 0; + }); + }); + + updateSequencerPadStates(); + updateStatus("Pattern randomized"); +} + +function refreshKeyLabels() { + document.querySelectorAll(".key").forEach((button) => { + const key = button.dataset.key; + const midi = noteForKey(key); + + button.setAttribute("aria-label", `Play ${noteNameFromMidi(midi)} with key ${key.toUpperCase()}`); + button.querySelector(".key__note").textContent = noteNameFromMidi(midi); + }); + + updateChordOverlay(chordOverlayRootKey); +} + +function applyMappingChange() { + releaseAllVoices(); + refreshKeyLabels(); + updateStatus(`Scale: ${SCALE_DEFINITIONS[currentScale].label} | Layout: ${LAYOUT_LABELS[currentLayout]} | Triads on board`); +} + +function pulseMetronomeLight(isAccent) { + dom.metronomeLight.classList.add("is-pulsing"); + dom.metronomeLight.classList.toggle("is-accent", isAccent); + + window.setTimeout(() => { + dom.metronomeLight.classList.remove("is-pulsing"); + dom.metronomeLight.classList.remove("is-accent"); + }, 90); +} + +function createVoice(midiNote, texture) { + const now = audioContext.currentTime; + const frequency = midiToFrequency(midiNote); + const voiceGain = audioContext.createGain(); + const filter = audioContext.createBiquadFilter(); + const output = audioContext.createGain(); + const reverbGain = audioContext.createGain(); + const oscillators = []; + const lfos = []; + const oneShots = []; + + voiceGain.gain.value = 0; + output.gain.value = 1; + filter.type = "lowpass"; + filter.frequency.value = 7000; + filter.Q.value = 0.9; + reverbGain.gain.value = 0.18; + + output.connect(masterFilter); + output.connect(reverbGain); + reverbGain.connect(convolver); + voiceGain.connect(filter); + filter.connect(output); + + const addOscillator = (type, detuneCents = 0, gainValue = 0.25) => { + const oscillator = audioContext.createOscillator(); + const amp = audioContext.createGain(); + + oscillator.type = type; + oscillator.frequency.value = frequency; + oscillator.detune.value = detuneCents; + amp.gain.value = gainValue; + + oscillator.connect(amp); + amp.connect(voiceGain); + oscillator.start(now); + + oscillators.push(oscillator); + }; + + const addTransientOscillator = (type = "sine", ratio = 4, peak = 0.025, duration = 0.028) => { + const transient = audioContext.createOscillator(); + const transientGain = audioContext.createGain(); + + transient.type = type; + transient.frequency.setValueAtTime(frequency * ratio, now); + transientGain.gain.setValueAtTime(0.0001, now); + transientGain.gain.exponentialRampToValueAtTime(peak, now + 0.0015); + transientGain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + transient.connect(transientGain); + transientGain.connect(output); + transient.start(now); + transient.stop(now + duration + 0.01); + oneShots.push(transient); + }; + + if (texture === "piano") { + addOscillator("triangle", 0, 0.16); + addOscillator("sine", 2, 0.11); + addOscillator("triangle", 1200, 0.035); + addTransientOscillator("sine", 4, 0.018, 0.022); + filter.frequency.setValueAtTime(5200, now); + filter.frequency.linearRampToValueAtTime(2600, now + 0.24); + output.gain.value = 0.92; + reverbGain.gain.value = 0.07; + voiceGain.gain.linearRampToValueAtTime(0.24, now + 0.003); + voiceGain.gain.exponentialRampToValueAtTime(0.0001, now + 1.35); + } else if (texture === "warm") { + addOscillator("sawtooth", -8, 0.18); + addOscillator("triangle", 8, 0.15); + filter.frequency.setValueAtTime(2200, now); + filter.frequency.linearRampToValueAtTime(5200, now + 0.22); + voiceGain.gain.linearRampToValueAtTime(0.24, now + 0.03); + } else if (texture === "glass") { + addOscillator("sine", 0, 0.19); + addOscillator("triangle", 7, 0.12); + filter.frequency.value = 6800; + output.gain.value = 0.92; + reverbGain.gain.value = 0.24; + voiceGain.gain.linearRampToValueAtTime(0.2, now + 0.18); + + const vibrato = audioContext.createOscillator(); + const vibratoDepth = audioContext.createGain(); + vibrato.frequency.value = 5.5; + vibratoDepth.gain.value = 10; + vibrato.connect(vibratoDepth); + oscillators.forEach((oscillator) => vibratoDepth.connect(oscillator.frequency)); + vibrato.start(now); + lfos.push(vibrato); + } else if (texture === "pluck") { + addOscillator("triangle", 0, 0.24); + addOscillator("square", 3, 0.06); + filter.frequency.setValueAtTime(5400, now); + filter.frequency.exponentialRampToValueAtTime(900, now + 0.35); + voiceGain.gain.linearRampToValueAtTime(0.28, now + 0.005); + voiceGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.42); + } else if (texture === "organ") { + addOscillator("sine", 0, 0.17); + addOscillator("square", 1200, 0.045); + addOscillator("triangle", 1900, 0.035); + filter.frequency.value = 5200; + output.gain.value = 0.94; + voiceGain.gain.linearRampToValueAtTime(0.22, now + 0.02); + } + + return { + release() { + const releaseAt = audioContext.currentTime; + + if (texture === "pluck") { + output.gain.cancelScheduledValues(releaseAt); + output.gain.setValueAtTime(Math.max(output.gain.value, 0.0001), releaseAt); + output.gain.exponentialRampToValueAtTime(0.0001, releaseAt + 0.08); + } else if (texture === "piano") { + voiceGain.gain.cancelScheduledValues(releaseAt); + voiceGain.gain.setValueAtTime(Math.max(voiceGain.gain.value, 0.0001), releaseAt); + voiceGain.gain.exponentialRampToValueAtTime(0.0001, releaseAt + 0.35); + } else if (texture === "glass") { + voiceGain.gain.cancelScheduledValues(releaseAt); + voiceGain.gain.setValueAtTime(Math.max(voiceGain.gain.value, 0.0001), releaseAt); + voiceGain.gain.exponentialRampToValueAtTime(0.0001, releaseAt + 1.1); + } else if (texture === "organ") { + voiceGain.gain.cancelScheduledValues(releaseAt); + voiceGain.gain.setValueAtTime(Math.max(voiceGain.gain.value, 0.0001), releaseAt); + voiceGain.gain.exponentialRampToValueAtTime(0.0001, releaseAt + 0.26); + } else { + voiceGain.gain.cancelScheduledValues(releaseAt); + voiceGain.gain.setValueAtTime(Math.max(voiceGain.gain.value, 0.0001), releaseAt); + voiceGain.gain.exponentialRampToValueAtTime(0.0001, releaseAt + 0.45); + } + + oscillators.forEach((oscillator) => oscillator.stop(releaseAt + 1.4)); + lfos.forEach((lfo) => lfo.stop(releaseAt + 1.4)); + oneShots.forEach((source) => { + try { + source.stop(releaseAt + 0.1); + } catch (error) { + // The hammer transient may have already finished. + } + }); + }, + }; +} + +function noteForKey(key) { + return 12 * (currentOctave + 1) + scaleDegreeToSemitone(getDegreeForKey(key)); +} + +function updateActiveState(key, isActive) { + const button = document.querySelector(`.key[data-key="${key}"]`); + if (button) { + button.classList.toggle("is-active", isActive); + } +} + +function triggerKeyDown(key) { + if (activeKeys.has(key) || !PLAYABLE_KEYS.has(key) || !audioContext) { + return; + } + + updateChordOverlay(key); + + const midiNote = noteForKey(key); + const voice = createVoice(midiNote, currentTexture); + + activeKeys.add(key); + activeVoices.set(key, voice); + updateActiveState(key, true); + updateStatus(`${key.toUpperCase()} -> ${noteNameFromMidi(midiNote)} | ${TEXTURE_LABELS[currentTexture]}`); +} + +function triggerKeyUp(key) { + if (!activeKeys.has(key)) { + return; + } + + activeKeys.delete(key); + updateActiveState(key, false); + + const voice = activeVoices.get(key); + if (voice) { + voice.release(); + activeVoices.delete(key); + } + + if (activeKeys.size === 0) { + resetStatus(); + } +} + +function releaseAllVoices() { + [...activeKeys].forEach((key) => triggerKeyUp(key)); +} + +function clampOctave(nextOctave) { + return Math.min(6, Math.max(2, nextOctave)); +} + +function createNoiseSource() { + const source = audioContext.createBufferSource(); + source.buffer = noiseBuffer; + return source; +} + +function scheduleKick(time) { + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + const clickOscillator = audioContext.createOscillator(); + const clickGain = audioContext.createGain(); + + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(150, time); + oscillator.frequency.exponentialRampToValueAtTime(48, time + 0.18); + gain.gain.setValueAtTime(0.0001, time); + gain.gain.exponentialRampToValueAtTime(0.95, time + 0.002); + gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.22); + + clickOscillator.type = "triangle"; + clickOscillator.frequency.setValueAtTime(900, time); + clickGain.gain.setValueAtTime(0.0001, time); + clickGain.gain.exponentialRampToValueAtTime(0.12, time + 0.001); + clickGain.gain.exponentialRampToValueAtTime(0.0001, time + 0.018); + + oscillator.connect(gain); + gain.connect(masterGain); + clickOscillator.connect(clickGain); + clickGain.connect(masterGain); + + oscillator.start(time); + oscillator.stop(time + 0.24); + clickOscillator.start(time); + clickOscillator.stop(time + 0.03); +} + +function scheduleSnare(time) { + const noise = createNoiseSource(); + const noiseFilter = audioContext.createBiquadFilter(); + const noiseGain = audioContext.createGain(); + const tone = audioContext.createOscillator(); + const toneGain = audioContext.createGain(); + + noiseFilter.type = "bandpass"; + noiseFilter.frequency.value = 1900; + noiseFilter.Q.value = 0.7; + noiseGain.gain.setValueAtTime(0.0001, time); + noiseGain.gain.exponentialRampToValueAtTime(0.48, time + 0.001); + noiseGain.gain.exponentialRampToValueAtTime(0.0001, time + 0.16); + + tone.type = "triangle"; + tone.frequency.setValueAtTime(220, time); + tone.frequency.exponentialRampToValueAtTime(140, time + 0.12); + toneGain.gain.setValueAtTime(0.0001, time); + toneGain.gain.exponentialRampToValueAtTime(0.1, time + 0.001); + toneGain.gain.exponentialRampToValueAtTime(0.0001, time + 0.1); + + noise.connect(noiseFilter); + noiseFilter.connect(noiseGain); + noiseGain.connect(masterGain); + tone.connect(toneGain); + toneGain.connect(masterGain); + + noise.start(time); + noise.stop(time + 0.2); + tone.start(time); + tone.stop(time + 0.12); +} + +function scheduleHat(time) { + const noise = createNoiseSource(); + const highpass = audioContext.createBiquadFilter(); + const gain = audioContext.createGain(); + + highpass.type = "highpass"; + highpass.frequency.value = 7000; + gain.gain.setValueAtTime(0.0001, time); + gain.gain.exponentialRampToValueAtTime(0.16, time + 0.001); + gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.06); + + noise.connect(highpass); + highpass.connect(gain); + gain.connect(masterGain); + + noise.start(time); + noise.stop(time + 0.08); +} + +function scheduleClap(time) { + const noise = createNoiseSource(); + const bandpass = audioContext.createBiquadFilter(); + const gain = audioContext.createGain(); + + bandpass.type = "bandpass"; + bandpass.frequency.value = 1200; + bandpass.Q.value = 0.8; + + const pulses = [0, 0.018, 0.036]; + gain.gain.setValueAtTime(0.0001, time); + + pulses.forEach((offset, index) => { + const peak = index === 0 ? 0.32 : 0.24; + gain.gain.exponentialRampToValueAtTime(peak, time + offset + 0.001); + gain.gain.exponentialRampToValueAtTime(0.0001, time + offset + 0.03); + }); + + noise.connect(bandpass); + bandpass.connect(gain); + gain.connect(masterGain); + + noise.start(time); + noise.stop(time + 0.12); +} + +function scheduleDrumHit(laneId, time) { + if (!audioContext) { + return; + } + + if (laneId === "kick") { + scheduleKick(time); + } else if (laneId === "snare") { + scheduleSnare(time); + } else if (laneId === "hat") { + scheduleHat(time); + } else if (laneId === "clap") { + scheduleClap(time); + } +} + +async function previewDrum(laneId) { + await ensureAudio(); + scheduleDrumHit(laneId, audioContext.currentTime + 0.001); +} + +function isTransportRunning() { + return metronomeState.enabled || sequencerState.enabled; +} + +function startTransport(resetPhase = false) { + if (!audioContext) { + return; + } + + if (resetPhase) { + transportStep = 0; + } + + nextTransportStepTime = audioContext.currentTime + 0.05; + + if (transportTimer) { + return; + } + + scheduleTransport(); + transportTimer = window.setInterval(scheduleTransport, transportState.lookaheadMs); +} + +function stopTransport() { + if (transportTimer) { + window.clearInterval(transportTimer); + transportTimer = null; + } + + clearSequencerPlayhead(); +} + +function syncTransportAfterTempoChange() { + if (audioContext && isTransportRunning()) { + nextTransportStepTime = audioContext.currentTime + 0.05; + } +} + +function scheduleMetronomeTick(time, isAccent) { + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + const filter = audioContext.createBiquadFilter(); + + oscillator.type = "square"; + oscillator.frequency.value = isAccent ? 1620 : 1080; + gain.gain.setValueAtTime(0.0001, time); + gain.gain.exponentialRampToValueAtTime(isAccent ? 0.13 : 0.08, time + 0.001); + gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.045); + + filter.type = "bandpass"; + filter.frequency.value = isAccent ? 1800 : 1200; + filter.Q.value = 4; + + oscillator.connect(filter); + filter.connect(gain); + gain.connect(masterGain); + + oscillator.start(time); + oscillator.stop(time + 0.06); + + const delayMs = Math.max(0, (time - audioContext.currentTime) * 1000); + window.setTimeout(() => { + if (metronomeState.enabled) { + pulseMetronomeLight(isAccent); + } + }, delayMs); +} + +function scheduleSequencerStep(step, time) { + SEQUENCER_LANES.forEach((lane) => { + if (sequencerState.patterns[lane.id][step]) { + scheduleDrumHit(lane.id, time); + } + }); + + const delayMs = Math.max(0, (time - audioContext.currentTime) * 1000); + window.setTimeout(() => { + if (sequencerState.enabled) { + setSequencerPlayhead(step); + } + }, delayMs); +} + +function scheduleTransport() { + if (!audioContext || !isTransportRunning()) { + return; + } + + while (nextTransportStepTime < audioContext.currentTime + transportState.scheduleAheadTime) { + const step = transportStep % sequencerState.steps; + + if (metronomeState.enabled && step % 4 === 0) { + scheduleMetronomeTick(nextTransportStepTime, step === 0); + } + + if (sequencerState.enabled) { + scheduleSequencerStep(step, nextTransportStepTime); + } + + nextTransportStepTime += 60 / transportState.tempo / 4; + transportStep += 1; + } +} + +async function startMetronome() { + if (metronomeState.enabled) { + return; + } + + await ensureAudio(); + const wasRunning = isTransportRunning(); + + metronomeState.enabled = true; + updateMetronomeButton(); + + if (!wasRunning) { + startTransport(true); + } + + resetStatus(); +} + +function stopMetronome() { + metronomeState.enabled = false; + updateMetronomeButton(); + dom.metronomeLight.classList.remove("is-pulsing"); + dom.metronomeLight.classList.remove("is-accent"); + + if (!isTransportRunning()) { + stopTransport(); + } + + resetStatus(); +} + +async function toggleMetronome() { + if (metronomeState.enabled) { + stopMetronome(); + return; + } + + await startMetronome(); +} + +async function startSequencer() { + if (sequencerState.enabled) { + return; + } + + await ensureAudio(); + const wasRunning = isTransportRunning(); + + sequencerState.enabled = true; + updateSequencerButton(); + + if (!wasRunning) { + startTransport(true); + } + + resetStatus(); +} + +function stopSequencer() { + sequencerState.enabled = false; + updateSequencerButton(); + clearSequencerPlayhead(); + + if (!isTransportRunning()) { + stopTransport(); + } + + resetStatus(); +} + +async function toggleSequencer() { + if (sequencerState.enabled) { + stopSequencer(); + return; + } + + await startSequencer(); +} + +function setupEvents() { + dom.audioToggle.addEventListener("click", ensureAudio); + dom.metronomeToggle.addEventListener("click", toggleMetronome); + dom.sequencerToggle.addEventListener("click", toggleSequencer); + + dom.textureSelect.addEventListener("change", () => { + currentTexture = dom.textureSelect.value; + updateStatus(`Texture: ${TEXTURE_LABELS[currentTexture]}`); + }); + + dom.scaleSelect.addEventListener("change", () => { + currentScale = dom.scaleSelect.value; + applyMappingChange(); + }); + + dom.layoutSelect.addEventListener("change", () => { + currentLayout = dom.layoutSelect.value; + applyMappingChange(); + }); + + dom.octaveDown.addEventListener("click", () => { + currentOctave = clampOctave(currentOctave - 1); + updateOctaveDisplay(); + refreshKeyLabels(); + updateStatus(`Octave ${currentOctave}`); + }); + + dom.octaveUp.addEventListener("click", () => { + currentOctave = clampOctave(currentOctave + 1); + updateOctaveDisplay(); + refreshKeyLabels(); + updateStatus(`Octave ${currentOctave}`); + }); + + dom.tempoSlider.addEventListener("input", () => { + transportState.tempo = Number(dom.tempoSlider.value); + updateTempoDisplay(); + syncTransportAfterTempoChange(); + resetStatus(); + }); + + dom.sequencerClear.addEventListener("click", () => { + clearSequencerPattern(); + updateStatus("Pattern cleared"); + }); + + dom.sequencerRandom.addEventListener("click", async () => { + randomizeSequencerPattern(); + await previewDrum("kick"); + }); + + window.addEventListener("keydown", async (event) => { + const key = event.key.toLowerCase(); + + if (event.metaKey || event.ctrlKey || event.altKey || !PLAYABLE_KEYS.has(key)) { + return; + } + + event.preventDefault(); + if (event.repeat) { + return; + } + + await ensureAudio(); + triggerKeyDown(key); + }); + + window.addEventListener("keyup", (event) => { + const key = event.key.toLowerCase(); + if (!PLAYABLE_KEYS.has(key)) { + return; + } + + event.preventDefault(); + triggerKeyUp(key); + }); + + window.addEventListener("blur", releaseAllVoices); +} + +buildKeyMap(); +buildSequencerGrid(); +updateOctaveDisplay(); +updateTempoDisplay(); +updateMetronomeButton(); +updateSequencerButton(); +resetStatus(); +setupEvents(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..59bab02 --- /dev/null +++ b/index.html @@ -0,0 +1,140 @@ + + + + + + Letterboard Synth + + + + + + + + +
+
+

Browser instrument

+

Play your laptop keyboard like a synth.

+

+ Letters map to notes in a friendly scale, so you can improvise fast. + Switch textures, shift octaves, and hold multiple keys at once. +

+
+ +
+ + + + +
+ Mapping +
+ + +
+
+ +
+ Octave +
+ + 4 + +
+
+ +
+ Metronome +
+ +
+ + 110 BPM +
+
+ +
+ +
+ Now playing +

Press any mapped letter

+
+
+ +
+
+
+
+
+ +
+
+
+

Beat sequencer

+

Build a groove under your keys.

+

+ Toggle steps to program drums. The pattern follows the same tempo as the metronome. +

+
+
+ + + +
+
+ +
+
+ +
+
+ Rows +

QWERTYUIOP, ASDFGHJKL, and ZXCVBNM are all mapped.

+
+
+ Texture +

Swap between mellow, airy, percussive, and sustained synth voices.

+
+
+ Tip +

Hold several letters together for chords. The board shows live R, 3, 5 overlays for the current shape.

+
+
+
+ + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..2fff39b --- /dev/null +++ b/styles.css @@ -0,0 +1,755 @@ +:root { + --bg: #08111f; + --panel: rgba(7, 20, 36, 0.72); + --panel-strong: rgba(12, 29, 50, 0.92); + --panel-border: rgba(152, 214, 255, 0.16); + --text: #eef7ff; + --muted: #9cb2c5; + --accent: #65f2d1; + --accent-2: #ffb36a; + --accent-3: #87a7ff; + --danger: #ff7c74; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.38); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + position: relative; + overflow-x: hidden; + background: + radial-gradient(circle at top, rgba(95, 151, 255, 0.18), transparent 30%), + linear-gradient(180deg, #091320 0%, #050a12 100%); + color: var(--text); + font-family: "Space Grotesk", sans-serif; +} + +button, +select { + font: inherit; +} + +code { + padding: 0.14rem 0.35rem; + border-radius: 0.45rem; + background: rgba(255, 255, 255, 0.08); + font-family: "IBM Plex Mono", monospace; + font-size: 0.92em; +} + +.backdrop { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.backdrop__orb { + position: absolute; + border-radius: 999px; + filter: blur(36px); + opacity: 0.55; +} + +.backdrop__orb--left { + top: 10%; + left: -8%; + width: 22rem; + height: 22rem; + background: rgba(101, 242, 209, 0.2); + animation: drift 16s ease-in-out infinite; +} + +.backdrop__orb--right { + right: -5%; + bottom: 8%; + width: 26rem; + height: 26rem; + background: rgba(255, 179, 106, 0.16); + animation: drift 20s ease-in-out infinite reverse; +} + +.backdrop__grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(135, 167, 255, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(135, 167, 255, 0.06) 1px, transparent 1px); + background-size: 3rem 3rem; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.6), transparent 85%); +} + +.app { + position: relative; + z-index: 1; + width: min(1100px, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 3rem; +} + +.hero { + padding: 1rem 0 2rem; + animation: rise 700ms ease-out both; +} + +.eyebrow { + margin: 0 0 0.75rem; + color: var(--accent); + font-family: "IBM Plex Mono", monospace; + font-size: 0.9rem; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.hero h1 { + margin: 0; + max-width: 12ch; + font-size: clamp(2.7rem, 7vw, 5.8rem); + line-height: 0.92; + letter-spacing: -0.05em; +} + +.hero__copy { + max-width: 42rem; + margin: 1.1rem 0 0; + color: var(--muted); + font-size: clamp(1rem, 2.2vw, 1.18rem); + line-height: 1.6; +} + +.controls, +.legend { + display: grid; + gap: 1rem; +} + +.controls { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + align-items: stretch; + margin-bottom: 1.4rem; + animation: rise 850ms ease-out both; +} + +.power, +.control-card, +.key { + border: 1px solid var(--panel-border); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.power, +.control-card { + min-height: 5.4rem; + border-radius: 1.25rem; + background: var(--panel); +} + +.power { + padding: 1rem 1.25rem; + color: var(--text); + font-weight: 700; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.08em; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.power:hover, +.power:focus-visible { + transform: translateY(-2px); + border-color: rgba(101, 242, 209, 0.5); + background: rgba(11, 35, 58, 0.96); +} + +.power--armed { + border-color: rgba(101, 242, 209, 0.7); + color: #021117; + background: linear-gradient(135deg, var(--accent), #c2fff0); +} + +.control-card { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.45rem; + padding: 0.95rem 1.15rem; +} + +.control-card__label { + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +select { + width: 100%; + padding: 0.7rem 0.85rem; + border: 1px solid rgba(152, 214, 255, 0.18); + border-radius: 0.85rem; + background: rgba(5, 12, 22, 0.55); + color: var(--text); +} + +.mapping-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +.mini-field { + display: grid; + gap: 0.35rem; +} + +.mini-field span { + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.76rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.octave-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.octave-controls button, +.octave-controls output { + display: grid; + place-items: center; + height: 2.5rem; + min-width: 2.5rem; + border-radius: 999px; +} + +.octave-controls button { + border: 1px solid rgba(152, 214, 255, 0.18); + background: rgba(5, 12, 22, 0.55); + color: var(--text); + cursor: pointer; +} + +.octave-controls output { + padding: 0 0.8rem; + background: rgba(255, 255, 255, 0.06); + font-weight: 700; +} + +.note-display { + margin: 0; + font-size: 1.05rem; +} + +.metronome-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} + +.metronome-toggle { + padding: 0.55rem 0.9rem; + border: 1px solid rgba(152, 214, 255, 0.18); + border-radius: 999px; + background: rgba(5, 12, 22, 0.55); + color: var(--text); + cursor: pointer; +} + +.metronome-toggle.is-running { + border-color: rgba(255, 179, 106, 0.7); + background: rgba(77, 44, 15, 0.8); +} + +.metronome-status { + display: flex; + align-items: center; + gap: 0.55rem; + color: var(--text); + font-family: "IBM Plex Mono", monospace; + font-size: 0.9rem; +} + +.metronome-light { + width: 0.8rem; + height: 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + box-shadow: inset 0 0 0 1px rgba(152, 214, 255, 0.14); + transition: background 100ms ease, box-shadow 100ms ease, transform 100ms ease; +} + +.metronome-light.is-pulsing { + background: var(--accent-2); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.1), + 0 0 18px rgba(255, 179, 106, 0.5); + transform: scale(1.15); +} + +.metronome-light.is-accent { + background: var(--accent); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.1), + 0 0 20px rgba(101, 242, 209, 0.55); +} + +.tempo-control { + display: grid; + gap: 0.45rem; + margin-top: 0.35rem; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.82rem; +} + +.tempo-control input { + width: 100%; + accent-color: var(--accent-2); +} + +.board { + display: grid; + gap: 0.9rem; + padding: 1.3rem; + border: 1px solid rgba(152, 214, 255, 0.14); + border-radius: 1.8rem; + background: linear-gradient(180deg, rgba(9, 22, 38, 0.78), rgba(7, 15, 28, 0.92)); + box-shadow: var(--shadow); + animation: rise 1s ease-out both; +} + +.sequencer { + margin-top: 1.1rem; + padding: 1.35rem; + border: 1px solid rgba(152, 214, 255, 0.14); + border-radius: 1.8rem; + background: linear-gradient(180deg, rgba(10, 22, 38, 0.84), rgba(8, 16, 28, 0.94)); + box-shadow: var(--shadow); + animation: rise 1.08s ease-out both; +} + +.sequencer__header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.sequencer__header h2 { + margin: 0; + font-size: clamp(1.5rem, 4vw, 2.4rem); + line-height: 1; + letter-spacing: -0.04em; +} + +.sequencer__copy { + max-width: 36rem; + margin: 0.5rem 0 0; + color: var(--muted); + line-height: 1.55; +} + +.sequencer__transport { + display: flex; + flex-wrap: wrap; + justify-content: end; + gap: 0.65rem; +} + +.sequencer-button { + padding: 0.72rem 1rem; + border: 1px solid rgba(101, 242, 209, 0.28); + border-radius: 999px; + background: rgba(14, 42, 56, 0.9); + color: var(--text); + cursor: pointer; + transition: transform 140ms ease, border-color 140ms ease, background 140ms ease; +} + +.sequencer-button:hover, +.sequencer-button:focus-visible { + transform: translateY(-2px); + border-color: rgba(101, 242, 209, 0.55); +} + +.sequencer-button.is-running { + border-color: rgba(255, 179, 106, 0.78); + background: rgba(78, 46, 17, 0.88); +} + +.sequencer-button--ghost { + border-color: rgba(152, 214, 255, 0.18); + background: rgba(5, 12, 22, 0.55); +} + +.sequencer-grid { + overflow-x: auto; +} + +.sequencer-grid__inner { + display: grid; + grid-template-columns: 8rem repeat(16, minmax(2.4rem, 1fr)); + gap: 0.5rem; + min-width: 54rem; +} + +.sequencer-grid__step-label, +.sequencer-lane__name, +.sequencer-step { + min-height: 3rem; +} + +.sequencer-grid__step-label { + display: grid; + place-items: center; + border-radius: 0.8rem; + color: rgba(238, 247, 255, 0.6); + font-family: "IBM Plex Mono", monospace; + font-size: 0.8rem; + background: rgba(255, 255, 255, 0.04); +} + +.sequencer-grid__step-label.is-accent { + color: var(--accent-2); +} + +.sequencer-grid__corner { + background: transparent; +} + +.sequencer-lane__name { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 0.9rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.04); +} + +.sequencer-lane__dot { + width: 0.8rem; + height: 0.8rem; + border-radius: 999px; +} + +.sequencer-lane__dot--kick { + background: #65f2d1; +} + +.sequencer-lane__dot--snare { + background: #ffb36a; +} + +.sequencer-lane__dot--hat { + background: #87a7ff; +} + +.sequencer-lane__dot--clap { + background: #ff7c74; +} + +.sequencer-lane__label { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.sequencer-lane__label strong { + font-size: 0.96rem; +} + +.sequencer-lane__label span { + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.sequencer-step { + border: 1px solid rgba(152, 214, 255, 0.14); + border-radius: 0.95rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02)), + rgba(9, 23, 37, 0.92); + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.sequencer-step:hover, +.sequencer-step:focus-visible { + transform: translateY(-2px); + border-color: rgba(152, 214, 255, 0.32); +} + +.sequencer-step.is-active { + border-color: rgba(101, 242, 209, 0.6); + background: + linear-gradient(180deg, rgba(101, 242, 209, 0.24), rgba(255, 179, 106, 0.08)), + rgba(14, 41, 57, 0.95); + box-shadow: inset 0 0 0 1px rgba(101, 242, 209, 0.14); +} + +.sequencer-step.is-current { + box-shadow: + 0 0 0 1px rgba(255, 179, 106, 0.4), + 0 0 20px rgba(255, 179, 106, 0.18); +} + +.sequencer-step.is-accent { + border-color: rgba(255, 179, 106, 0.16); +} + +.board-row { + display: grid; + grid-template-columns: repeat(10, minmax(0, 1fr)); + gap: 0.8rem; +} + +.board-row--offset { + grid-template-columns: repeat(9, minmax(0, 1fr)); + padding-left: 2.5rem; +} + +.board-row--wide { + grid-template-columns: repeat(7, minmax(0, 1fr)); + padding-right: 12rem; +} + +.key { + position: relative; + min-height: 7.2rem; + padding: 0.9rem; + border-radius: 1.2rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02)), + var(--panel-strong); + color: var(--text); + text-align: left; + cursor: pointer; + touch-action: none; + transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease; +} + +.key:hover, +.key:focus-visible { + transform: translateY(-3px); + border-color: rgba(135, 167, 255, 0.45); +} + +.key__letter, +.key__note { + display: block; +} + +.key__shape-role, +.key__shape-name { + position: absolute; + border-radius: 999px; + font-family: "IBM Plex Mono", monospace; + line-height: 1; + pointer-events: none; +} + +.key__letter { + font-size: 1.45rem; + font-weight: 700; +} + +.key__note { + margin-top: 0.35rem; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.95rem; +} + +.key__shape-role { + top: 0.75rem; + right: 0.75rem; + min-width: 1.7rem; + padding: 0.25rem 0.35rem; + background: rgba(255, 255, 255, 0.08); + color: rgba(238, 247, 255, 0.76); + font-size: 0.74rem; + text-align: center; +} + +.key__shape-name { + left: 0.75rem; + bottom: 0.72rem; + padding: 0.22rem 0.42rem; + background: rgba(101, 242, 209, 0.12); + color: rgba(101, 242, 209, 0.9); + font-size: 0.68rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.key__index { + position: absolute; + right: 0.9rem; + bottom: 0.9rem; + color: rgba(238, 247, 255, 0.28); + font-family: "IBM Plex Mono", monospace; + font-size: 0.78rem; +} + +.key.is-chord-tone { + border-color: rgba(135, 167, 255, 0.34); + box-shadow: + 0 10px 28px rgba(0, 0, 0, 0.26), + inset 0 0 0 1px rgba(135, 167, 255, 0.08); +} + +.key.is-chord-root { + border-color: rgba(255, 179, 106, 0.56); + box-shadow: + 0 14px 30px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 179, 106, 0.12), + 0 0 30px rgba(255, 179, 106, 0.12); +} + +.key.is-chord-root .key__shape-role { + background: rgba(255, 179, 106, 0.18); + color: #ffd7b2; +} + +.key.is-chord-tone .key__shape-role { + background: rgba(135, 167, 255, 0.14); + color: #ccdaff; +} + +.key.is-active { + transform: translateY(-4px) scale(1.01); + border-color: rgba(101, 242, 209, 0.8); + background: + linear-gradient(180deg, rgba(101, 242, 209, 0.18), rgba(255, 179, 106, 0.08)), + rgba(14, 38, 56, 0.96); + box-shadow: + 0 18px 38px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(101, 242, 209, 0.15), + 0 0 38px rgba(101, 242, 209, 0.24); +} + +.legend { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 1rem; + animation: rise 1.1s ease-out both; +} + +.legend__item { + padding: 1rem 1.1rem; + border: 1px solid rgba(152, 214, 255, 0.12); + border-radius: 1.1rem; + background: rgba(7, 20, 36, 0.56); +} + +.legend__title { + display: block; + margin-bottom: 0.45rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.82rem; + color: var(--accent-2); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.legend p { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +@keyframes drift { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(1.2rem, -1rem, 0) scale(1.08); + } +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 900px) { + .controls, + .legend { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mapping-grid { + grid-template-columns: 1fr; + } + + .sequencer__header { + align-items: start; + flex-direction: column; + } + + .sequencer__transport { + justify-content: start; + } + + .board-row--offset, + .board-row--wide { + padding: 0; + } +} + +@media (max-width: 700px) { + .app { + width: min(100% - 1rem, 48rem); + padding-top: 1.2rem; + } + + .controls, + .legend { + grid-template-columns: 1fr; + } + + .board { + padding: 0.9rem; + gap: 0.7rem; + } + + .board-row { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .board-row--offset { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .board-row--wide { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .key { + min-height: 5.6rem; + } +}