1188 lines
33 KiB
JavaScript
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();
|