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();