music/app.js
2026-03-02 20:09:22 +01:00

1188 lines
33 KiB
JavaScript

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 = `
<span class="key__letter">${key.toUpperCase()}</span>
<span class="key__note">${noteNameFromMidi(midi)}</span>
<span class="key__shape-role" aria-hidden="true"></span>
<span class="key__shape-name" aria-hidden="true"></span>
<span class="key__index">${index + 1}</span>
`;
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 = `
<span class="sequencer-lane__dot sequencer-lane__dot--${lane.color}" aria-hidden="true"></span>
<span class="sequencer-lane__label">
<strong>${lane.label}</strong>
<span>${lane.hint}</span>
</span>
`;
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();