Skip to content

Instantly share code, notes, and snippets.

@rndmcnlly
Last active January 28, 2026 06:31
Show Gist options
  • Select an option

  • Save rndmcnlly/d77469c94edb1e6c4930de57baebac75 to your computer and use it in GitHub Desktop.

Select an option

Save rndmcnlly/d77469c94edb1e6c4930de57baebac75 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Ambient Synth Rack</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; user-select: none; }
body {
min-height: 100vh;
background: linear-gradient(135deg, #d4d4d8 0%, #e4e4e7 50%, #d8d8dc 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
color: #374151;
padding: 16px;
overflow-x: hidden;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
h1 {
font-size: 14px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: #6b7280;
}
.transport-btn {
background: linear-gradient(to bottom, #ffffff, #f3f4f6);
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px 28px;
font-size: 13px;
font-weight: 600;
color: #374151;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.06), inset 0 1px 0 white;
transition: all 0.15s;
}
.transport-btn:hover { background: linear-gradient(to bottom, #ffffff, #e5e7eb); }
.transport-btn:active { box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); }
.transport-btn.playing {
background: linear-gradient(to bottom, #dcfce7, #bbf7d0);
border-color: #86efac;
color: #166534;
}
/* Device Rack */
.rack {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
@media (max-width: 600px) {
.rack { grid-template-columns: repeat(2, 1fr); }
}
.device {
background: linear-gradient(to bottom, #fafafa, #f0f0f2);
border: 1px solid #c8c8cc;
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08), inset 0 1px 0 white;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.device-name {
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #6b7280;
}
.device-led {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
transition: all 0.1s;
}
.device-led.on {
background: #4ade80;
box-shadow: 0 0 8px #4ade80, inset 0 1px 2px rgba(255,255,255,0.3);
}
.device-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
/* Knob */
.knob-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.knob {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #e8e8ea, #d0d0d4);
box-shadow: 0 4px 8px rgba(0,0,0,0.15), inset 0 2px 4px rgba(255,255,255,0.8);
position: relative;
cursor: pointer;
transition: box-shadow 0.15s;
}
.knob::before {
content: '';
position: absolute;
inset: 4px;
border-radius: 50%;
background: linear-gradient(to bottom, #f8f8fa, #e8e8ec);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.knob.active {
box-shadow: 0 4px 8px rgba(0,0,0,0.15), 0 0 0 4px rgba(59, 130, 246, 0.4), inset 0 2px 4px rgba(255,255,255,0.8);
}
.knob-indicator {
position: absolute;
width: 3px;
height: 12px;
background: #374151;
border-radius: 2px;
left: 50%;
top: 8px;
transform-origin: center 20px;
transform: translateX(-50%) rotate(-135deg);
z-index: 1;
}
.knob-label {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #9ca3af;
}
.knob-value {
font-size: 11px;
font-weight: 600;
color: #6b7280;
font-variant-numeric: tabular-nums;
}
/* Trigger Button */
.trigger-btn {
width: 100%;
padding: 14px 16px;
background: linear-gradient(to bottom, #fef3c7, #fde68a);
border: 1px solid #f59e0b;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #92400e;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.5);
transition: all 0.1s;
}
.trigger-btn:active, .trigger-btn.active {
background: linear-gradient(to bottom, #fde68a, #fcd34d);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(1px);
}
/* Sequencer */
.sequencer {
background: linear-gradient(to bottom, #27272a, #18181b);
border: 1px solid #3f3f46;
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05);
}
.seq-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.seq-row:last-child { margin-bottom: 0; }
.seq-label {
width: 50px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #71717a;
flex-shrink: 0;
}
.seq-steps {
display: flex;
gap: 4px;
flex: 1;
}
.seq-step {
flex: 1;
height: 24px;
background: #3f3f46;
border-radius: 3px;
transition: all 0.1s;
position: relative;
}
.seq-step.active {
background: #52525b;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1);
}
.seq-step.current {
box-shadow: inset 0 0 0 2px #3b82f6;
}
.seq-step.note {
background: linear-gradient(to bottom, #4ade80, #22c55e);
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}
.seq-step.note.pad { background: linear-gradient(to bottom, #818cf8, #6366f1); box-shadow: 0 0 8px rgba(129, 140, 248, 0.4); }
.seq-step.note.clank { background: linear-gradient(to bottom, #fbbf24, #f59e0b); box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); }
.seq-step.note.melody { background: linear-gradient(to bottom, #f472b6, #ec4899); box-shadow: 0 0 8px rgba(244, 114, 182, 0.4); }
.seq-beat-marker {
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
background: #52525b;
border-radius: 50%;
}
.seq-step:nth-child(4n+1) .seq-beat-marker { background: #71717a; }
/* Mixer */
.mixer {
background: linear-gradient(to bottom, #fafafa, #f0f0f2);
border: 1px solid #c8c8cc;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08), inset 0 1px 0 white;
}
.mixer-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #9ca3af;
margin-bottom: 16px;
}
.mixer-channels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.channel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.channel-name {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #6b7280;
}
.fader-track {
width: 32px;
height: 120px;
background: linear-gradient(to bottom, #e5e7eb, #d1d5db);
border-radius: 4px;
position: relative;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.15);
cursor: pointer;
}
.fader-track.active {
box-shadow: inset 0 2px 4px rgba(0,0,0,0.15), 0 0 0 3px rgba(59, 130, 246, 0.4);
}
.fader-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, #3b82f6, #60a5fa);
border-radius: 0 0 4px 4px;
transition: height 0.05s;
}
.fader-handle {
position: absolute;
left: -6px;
right: -6px;
height: 20px;
background: linear-gradient(to bottom, #ffffff, #e5e7eb);
border: 1px solid #9ca3af;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(50%);
}
.fader-db {
font-size: 10px;
font-weight: 600;
color: #9ca3af;
font-variant-numeric: tabular-nums;
}
.channel-buttons {
display: flex;
gap: 6px;
}
.mute-btn, .solo-btn {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid #d1d5db;
background: linear-gradient(to bottom, #ffffff, #f3f4f6);
font-size: 10px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
transition: all 0.1s;
}
.mute-btn { color: #9ca3af; }
.solo-btn { color: #9ca3af; }
.mute-btn.active {
background: linear-gradient(to bottom, #fca5a5, #f87171);
border-color: #ef4444;
color: #7f1d1d;
}
.solo-btn.active {
background: linear-gradient(to bottom, #fde047, #facc15);
border-color: #eab308;
color: #713f12;
}
.mute-btn:active, .solo-btn:active {
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
/* Meter */
.meter {
width: 6px;
height: 120px;
background: #27272a;
border-radius: 3px;
position: relative;
overflow: hidden;
}
.meter-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, #22c55e, #4ade80, #fbbf24, #ef4444);
transition: height 0.05s;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Ambient Synth Rack</h1>
<button class="transport-btn" id="transport">▶ START</button>
</header>
<div class="rack">
<div class="device" id="dev-arp">
<div class="device-header">
<span class="device-name">Arpeggio</span>
<div class="device-led" id="led-arp"></div>
</div>
<div class="device-controls">
<div class="knob-container">
<div class="knob" id="knob-arp-filter">
<div class="knob-indicator"></div>
</div>
<span class="knob-label">Filter</span>
<span class="knob-value" id="val-arp-filter">2.4k</span>
</div>
<div class="knob-container">
<div class="knob" id="knob-arp-res">
<div class="knob-indicator"></div>
</div>
<span class="knob-label">Resonance</span>
<span class="knob-value" id="val-arp-res">4.0</span>
</div>
</div>
</div>
<div class="device" id="dev-pad">
<div class="device-header">
<span class="device-name">Pad</span>
<div class="device-led" id="led-pad"></div>
</div>
<div class="device-controls">
<div class="knob-container">
<div class="knob" id="knob-pad-filter">
<div class="knob-indicator"></div>
</div>
<span class="knob-label">Warmth</span>
<span class="knob-value" id="val-pad-filter">1.2k</span>
</div>
<div class="knob-container">
<div class="knob" id="knob-pad-breath">
<div class="knob-indicator"></div>
</div>
<span class="knob-label">Breath</span>
<span class="knob-value" id="val-pad-breath">0.08</span>
</div>
</div>
</div>
<div class="device" id="dev-clank">
<div class="device-header">
<span class="device-name">Clanks</span>
<div class="device-led" id="led-clank"></div>
</div>
<div class="device-controls">
<button class="trigger-btn" id="clank-trigger">⚡ Trigger</button>
<div class="knob-container">
<div class="knob" id="knob-clank-density">
<div class="knob-indicator"></div>
</div>
<span class="knob-label">Density</span>
<span class="knob-value" id="val-clank-density">35%</span>
</div>
</div>
</div>
<div class="device" id="dev-melody">
<div class="device-header">
<span class="device-name">Melody</span>
<div class="device-led" id="led-melody"></div>
</div>
<div class="device-controls">
<div class="knob-container">
<div class="knob" id="knob-melody-slide">
<div class="knob-indicator"></div>
</div>
<span class="knob-label">Slide</span>
<span class="knob-value" id="val-melody-slide">0.25s</span>
</div>
<div class="knob-container">
<div class="knob" id="knob-melody-vibrato">
<div class="knob-indicator"></div>
</div>
<span class="knob-label">Vibrato</span>
<span class="knob-value" id="val-melody-vibrato">4.5</span>
</div>
</div>
</div>
</div>
<div class="sequencer" id="sequencer">
<div class="seq-row">
<span class="seq-label">Arp</span>
<div class="seq-steps" id="seq-arp"></div>
</div>
<div class="seq-row">
<span class="seq-label">Pad</span>
<div class="seq-steps" id="seq-pad"></div>
</div>
<div class="seq-row">
<span class="seq-label">Clanks</span>
<div class="seq-steps" id="seq-clank"></div>
</div>
<div class="seq-row">
<span class="seq-label">Melody</span>
<div class="seq-steps" id="seq-melody"></div>
</div>
</div>
<div class="mixer">
<div class="mixer-title">Mixer</div>
<div class="mixer-channels">
<div class="channel" data-ch="arp">
<span class="channel-name">Arp</span>
<div style="display:flex;gap:8px;align-items:flex-end;">
<div class="fader-track" id="fader-arp">
<div class="fader-fill"></div>
<div class="fader-handle"></div>
</div>
<div class="meter"><div class="meter-fill" id="meter-arp"></div></div>
</div>
<span class="fader-db" id="db-arp">0.0 dB</span>
<div class="channel-buttons">
<button class="mute-btn" data-ch="arp">M</button>
<button class="solo-btn" data-ch="arp">S</button>
</div>
</div>
<div class="channel" data-ch="pad">
<span class="channel-name">Pad</span>
<div style="display:flex;gap:8px;align-items:flex-end;">
<div class="fader-track" id="fader-pad">
<div class="fader-fill"></div>
<div class="fader-handle"></div>
</div>
<div class="meter"><div class="meter-fill" id="meter-pad"></div></div>
</div>
<span class="fader-db" id="db-pad">0.0 dB</span>
<div class="channel-buttons">
<button class="mute-btn" data-ch="pad">M</button>
<button class="solo-btn" data-ch="pad">S</button>
</div>
</div>
<div class="channel" data-ch="clank">
<span class="channel-name">Clank</span>
<div style="display:flex;gap:8px;align-items:flex-end;">
<div class="fader-track" id="fader-clank">
<div class="fader-fill"></div>
<div class="fader-handle"></div>
</div>
<div class="meter"><div class="meter-fill" id="meter-clank"></div></div>
</div>
<span class="fader-db" id="db-clank">0.0 dB</span>
<div class="channel-buttons">
<button class="mute-btn" data-ch="clank">M</button>
<button class="solo-btn" data-ch="clank">S</button>
</div>
</div>
<div class="channel" data-ch="melody">
<span class="channel-name">Melody</span>
<div style="display:flex;gap:8px;align-items:flex-end;">
<div class="fader-track" id="fader-melody">
<div class="fader-fill"></div>
<div class="fader-handle"></div>
</div>
<div class="meter"><div class="meter-fill" id="meter-melody"></div></div>
</div>
<span class="fader-db" id="db-melody">0.0 dB</span>
<div class="channel-buttons">
<button class="mute-btn" data-ch="melody">M</button>
<button class="solo-btn" data-ch="melody">S</button>
</div>
</div>
</div>
</div>
</div>
<script>
// ==================== CONSTANTS ====================
const BPM = 60;
const STEP = 60 / BPM / 4;
const ROOT = 50;
const SCALE = [0, 2, 3, 5, 7, 9, 10, 12, 14, 15, 17, 19, 21, 24];
const CHORDS = [
[0, 3, 7, 10, 14],
[5, 8, 12, 15, 19],
[3, 7, 10, 14, 17],
[7, 10, 14, 17, 21],
[-2, 2, 5, 10, 14]
];
const mtof = m => 440 * Math.pow(2, (m - 69) / 12);
// ==================== UI CONTROLS ====================
class Knob {
constructor(el, opts) {
this.el = el;
this.indicator = el.querySelector('.knob-indicator');
this.min = opts.min ?? 0;
this.max = opts.max ?? 1;
this.value = opts.initial ?? 0.5;
this.onChange = opts.onChange;
this.format = opts.format || (v => v.toFixed(2));
this.valueEl = opts.valueEl;
this.dragging = false;
this.startY = 0;
this.startVal = 0;
el.addEventListener('mousedown', e => this.start(e));
el.addEventListener('touchstart', e => this.start(e), { passive: false });
document.addEventListener('mousemove', e => this.move(e));
document.addEventListener('touchmove', e => this.move(e), { passive: false });
document.addEventListener('mouseup', () => this.end());
document.addEventListener('touchend', () => this.end());
this.render();
}
start(e) {
e.preventDefault();
this.dragging = true;
this.startY = e.clientY ?? e.touches[0].clientY;
this.startVal = this.value;
this.el.classList.add('active');
}
move(e) {
if (!this.dragging) return;
e.preventDefault();
const y = e.clientY ?? e.touches[0].clientY;
const delta = (this.startY - y) / 150;
this.value = Math.max(this.min, Math.min(this.max, this.startVal + delta * (this.max - this.min)));
this.render();
this.onChange?.(this.value);
}
end() {
if (!this.dragging) return;
this.dragging = false;
this.el.classList.remove('active');
}
render() {
const norm = (this.value - this.min) / (this.max - this.min);
const rot = -135 + norm * 270;
this.indicator.style.transform = `translateX(-50%) rotate(${rot}deg)`;
if (this.valueEl) this.valueEl.textContent = this.format(this.value);
}
setValue(v) {
this.value = Math.max(this.min, Math.min(this.max, v));
this.render();
}
}
class Fader {
constructor(el, opts) {
this.el = el;
this.fill = el.querySelector('.fader-fill');
this.handle = el.querySelector('.fader-handle');
this.min = opts.min ?? 0;
this.max = opts.max ?? 1;
this.value = opts.initial ?? 0.8;
this.onChange = opts.onChange;
this.dbEl = opts.dbEl;
this.dragging = false;
this.trackHeight = 120;
el.addEventListener('mousedown', e => this.start(e));
el.addEventListener('touchstart', e => this.start(e), { passive: false });
document.addEventListener('mousemove', e => this.move(e));
document.addEventListener('touchmove', e => this.move(e), { passive: false });
document.addEventListener('mouseup', () => this.end());
document.addEventListener('touchend', () => this.end());
this.render();
}
start(e) {
e.preventDefault();
this.dragging = true;
this.el.classList.add('active');
this.move(e);
}
move(e) {
if (!this.dragging) return;
e.preventDefault();
const rect = this.el.getBoundingClientRect();
const y = e.clientY ?? e.touches[0].clientY;
const norm = 1 - Math.max(0, Math.min(1, (y - rect.top) / rect.height));
this.value = this.min + norm * (this.max - this.min);
this.render();
this.onChange?.(this.value);
}
end() {
if (!this.dragging) return;
this.dragging = false;
this.el.classList.remove('active');
}
render() {
const norm = (this.value - this.min) / (this.max - this.min);
const pct = norm * 100;
this.fill.style.height = pct + '%';
this.handle.style.bottom = `calc(${pct}% - 10px)`;
if (this.dbEl) {
const db = this.value > 0.001 ? 20 * Math.log10(this.value) : -60;
this.dbEl.textContent = (db > -60 ? db.toFixed(1) : '-∞') + ' dB';
}
}
}
// ==================== SEQUENCER UI ====================
function initSequencer() {
['arp', 'pad', 'clank', 'melody'].forEach(track => {
const container = document.getElementById(`seq-${track}`);
for (let i = 0; i < 16; i++) {
const step = document.createElement('div');
step.className = 'seq-step';
step.dataset.step = i;
const marker = document.createElement('div');
marker.className = 'seq-beat-marker';
step.appendChild(marker);
container.appendChild(step);
}
});
}
function updateSequencer(track, step, hasNote) {
const container = document.getElementById(`seq-${track}`);
if (!container) return;
const steps = container.querySelectorAll('.seq-step');
steps.forEach((s, i) => {
s.classList.remove('current');
if (i === step) s.classList.add('current');
});
if (hasNote && steps[step]) {
steps[step].classList.add('note', track);
setTimeout(() => steps[step].classList.remove('note', track), STEP * 900);
}
}
function flashLed(id) {
const led = document.getElementById(id);
if (!led) return;
led.classList.add('on');
setTimeout(() => led.classList.remove('on'), 100);
}
// ==================== AUDIO ENGINE ====================
class AmbientEngine {
constructor() {
this.ctx = null;
this.playing = false;
this.params = {
arpFilter: 2400,
arpRes: 4,
padFilter: 1200,
padBreath: 0.08,
clankDensity: 0.035,
melodySlide: 0.25,
melodyVibrato: 4.5
};
this.mixer = {
arp: { vol: 0.8, mute: false, solo: false },
pad: { vol: 0.8, mute: false, solo: false },
clank: { vol: 0.8, mute: false, solo: false },
melody: { vol: 0.8, mute: false, solo: false }
};
this.meters = { arp: 0, pad: 0, clank: 0, melody: 0 };
}
async init() {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
const c = this.ctx;
// Master chain
this.master = c.createGain();
this.master.gain.value = 0.7;
this.saturator = c.createWaveShaper();
this.saturator.curve = this.makeSaturationCurve(0.4);
this.masterFilter = c.createBiquadFilter();
this.masterFilter.type = 'lowpass';
this.masterFilter.frequency.value = 8000;
this.comp = c.createDynamicsCompressor();
this.comp.threshold.value = -20;
this.comp.knee.value = 20;
this.comp.ratio.value = 4;
this.comp.attack.value = 0.01;
this.comp.release.value = 0.3;
// Reverb
this.reverb = this.createReverb(4.5, 2.2);
this.reverbReturn = c.createGain();
this.reverbReturn.gain.value = 0.5;
// Delay
this.delay = c.createDelay(3);
this.delay.delayTime.value = STEP * 3;
this.delayFeedback = c.createGain();
this.delayFeedback.gain.value = 0.4;
this.delayFilter = c.createBiquadFilter();
this.delayFilter.type = 'lowpass';
this.delayFilter.frequency.value = 1800;
// Channel strips
this.channels = {};
['arp', 'pad', 'clank', 'melody'].forEach(name => {
const ch = {
input: c.createGain(),
fader: c.createGain(),
output: c.createGain(),
reverbSend: c.createGain(),
delaySend: c.createGain(),
analyser: c.createAnalyser()
};
ch.analyser.fftSize = 256;
ch.input.connect(ch.fader);
ch.fader.connect(ch.output);
ch.fader.connect(ch.analyser);
ch.output.connect(this.master);
ch.fader.connect(ch.reverbSend);
ch.reverbSend.connect(this.reverb);
ch.reverbSend.gain.value = name === 'clank' ? 0.4 : 0.2;
ch.fader.connect(ch.delaySend);
ch.delaySend.connect(this.delay);
ch.delaySend.gain.value = name === 'arp' ? 0.3 : 0.1;
this.channels[name] = ch;
});
// Routing
this.master.connect(this.saturator);
this.saturator.connect(this.masterFilter);
this.masterFilter.connect(this.comp);
this.reverb.connect(this.reverbReturn);
this.reverbReturn.connect(this.comp);
this.delay.connect(this.delayFilter);
this.delayFilter.connect(this.delayFeedback);
this.delayFeedback.connect(this.delay);
this.delayFilter.connect(this.reverbReturn);
this.comp.connect(c.destination);
// Instruments
this.arp = new Arpeggiator(c, this.channels.arp.input, this);
this.pad = new BreathyPad(c, this.channels.pad.input, this);
this.clanks = new IndustrialClanks(c, this.channels.clank.input, this);
this.melody = new TriangleMelody(c, this.channels.melody.input, this);
this.stepCount = 0;
this.chordIndex = 0;
this.nextTime = 0;
this.updateAllChannels();
this.startMeters();
}
makeSaturationCurve(amount) {
const samples = 256;
const curve = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const x = (i * 2) / samples - 1;
curve[i] = (Math.PI + amount) * x / (Math.PI + amount * Math.abs(x));
}
return curve;
}
createReverb(time, decay) {
const c = this.ctx;
const length = c.sampleRate * time;
const impulse = c.createBuffer(2, length, c.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < length; i++) {
const env = Math.pow(1 - i / length, decay);
data[i] = (Math.random() * 2 - 1) * env;
if (i < c.sampleRate * 0.1 && Math.random() > 0.98) {
data[i] += (Math.random() * 2 - 1) * 0.5;
}
}
}
const conv = c.createConvolver();
conv.buffer = impulse;
return conv;
}
updateChannel(name) {
const m = this.mixer[name];
const ch = this.channels[name];
const anySolo = Object.values(this.mixer).some(x => x.solo);
const shouldMute = m.mute || (anySolo && !m.solo);
const gain = shouldMute ? 0 : m.vol;
ch.output.gain.value = gain;
ch.reverbSend.gain.value = shouldMute ? 0 : (name === 'clank' ? 0.4 : 0.2);
ch.delaySend.gain.value = shouldMute ? 0 : (name === 'arp' ? 0.3 : 0.1);
}
updateAllChannels() {
['arp', 'pad', 'clank', 'melody'].forEach(n => this.updateChannel(n));
}
startMeters() {
const update = () => {
['arp', 'pad', 'clank', 'melody'].forEach(name => {
const ch = this.channels[name];
const arr = new Uint8Array(ch.analyser.frequencyBinCount);
ch.analyser.getByteFrequencyData(arr);
const avg = arr.reduce((a, b) => a + b, 0) / arr.length;
this.meters[name] = avg / 255;
const el = document.getElementById(`meter-${name}`);
if (el) el.style.height = (this.meters[name] * 100) + '%';
});
requestAnimationFrame(update);
};
update();
}
start() {
if (!this.ctx) return;
this.playing = true;
this.nextTime = this.ctx.currentTime + 0.1;
this.stepCount = 0;
this.chordIndex = 0;
this.arp.start(CHORDS[0]);
this.melody.start();
this.pad.setChord(CHORDS[0].map(n => ROOT + n), this.ctx.currentTime);
this.schedule();
}
schedule() {
if (!this.playing) return;
while (this.nextTime < this.ctx.currentTime + 0.25) {
const t = this.nextTime;
const step = this.stepCount % 16;
// Arpeggiator
const arpNote = this.arp.step(t);
updateSequencer('arp', step, arpNote);
if (arpNote) flashLed('led-arp');
// Industrial clanks
if (Math.random() < this.params.clankDensity) {
this.clanks.play(t + Math.random() * STEP);
updateSequencer('clank', step, true);
flashLed('led-clank');
} else {
updateSequencer('clank', step, false);
}
// Melody
if (this.stepCount % 12 === 0 || (this.stepCount % 8 === 4 && Math.random() > 0.6)) {
this.melody.playNote(t);
updateSequencer('melody', step, true);
flashLed('led-melody');
} else {
updateSequencer('melody', step, false);
}
// Pad visualization
updateSequencer('pad', step, this.stepCount % 16 < 2);
if (this.stepCount % 48 === 0) flashLed('led-pad');
// Chord progression
if (this.stepCount > 0 && this.stepCount % 48 === 0) {
this.chordIndex = (this.chordIndex + 1) % CHORDS.length;
const chord = CHORDS[this.chordIndex];
this.arp.setChord(chord);
this.pad.setChord(chord.map(n => ROOT + n), t);
}
this.nextTime += STEP;
this.stepCount++;
}
this.timer = setTimeout(() => this.schedule(), 50);
}
triggerClank() {
if (this.ctx && this.playing) {
this.clanks.play(this.ctx.currentTime);
flashLed('led-clank');
}
}
stop() {
this.playing = false;
clearTimeout(this.timer);
this.melody.stop();
this.pad.fadeOut();
}
}
// ==================== INSTRUMENTS ====================
class Arpeggiator {
constructor(ctx, output, engine) {
this.ctx = ctx;
this.engine = engine;
this.out = ctx.createGain();
this.out.gain.value = 0.14;
this.out.connect(output);
this.pattern = [];
this.idx = 0;
this.chord = [];
}
start(chord) {
this.setChord(chord);
this.generatePattern();
}
setChord(chord) {
this.chord = chord;
if (Math.random() > 0.5) this.generatePattern();
}
generatePattern() {
this.pattern = [];
const extensions = [...this.chord, ...this.chord.map(n => n + 12), ...this.chord.map(n => n - 12)];
for (let i = 0; i < 16; i++) {
if (Math.random() > 0.15) {
const note = extensions[Math.floor(Math.random() * extensions.length)];
const velocity = 0.4 + Math.random() * 0.6;
this.pattern.push({ note: ROOT + note, vel: velocity, dur: STEP * (1 + Math.floor(Math.random() * 3)) });
} else {
this.pattern.push(null);
}
}
}
step(time) {
const note = this.pattern[this.idx];
if (note) {
this.playNote(note.note, time, note.dur, note.vel);
}
this.idx = (this.idx + 1) % this.pattern.length;
if (this.idx === 0 && Math.random() > 0.6) {
this.generatePattern();
}
return !!note;
}
playNote(midi, time, dur, vel) {
const c = this.ctx;
const freq = mtof(midi);
const noteOut = c.createGain();
noteOut.connect(this.out);
const filt = c.createBiquadFilter();
filt.type = 'lowpass';
filt.Q.value = this.engine.params.arpRes;
filt.frequency.setValueAtTime(this.engine.params.arpFilter, time);
filt.frequency.exponentialRampToValueAtTime(400, time + dur * 0.9);
filt.connect(noteOut);
noteOut.gain.setValueAtTime(0, time);
noteOut.gain.linearRampToValueAtTime(vel * 0.5, time + 0.008);
noteOut.gain.exponentialRampToValueAtTime(vel * 0.2, time + dur * 0.3);
noteOut.gain.exponentialRampToValueAtTime(0.001, time + dur);
const detunes = [-8, -3, 0, 3, 8, 1200];
detunes.forEach((det, i) => {
const osc = c.createOscillator();
osc.type = i < 4 ? 'sawtooth' : 'triangle';
osc.frequency.value = freq;
osc.detune.value = det + (Math.random() - 0.5) * 4;
const g = c.createGain();
g.gain.value = i < 4 ? 0.25 : 0.15;
osc.connect(g);
g.connect(filt);
osc.start(time);
osc.stop(time + dur + 0.1);
});
}
}
class BreathyPad {
constructor(ctx, output, engine) {
this.ctx = ctx;
this.engine = engine;
this.out = ctx.createGain();
this.out.gain.value = 0.11;
this.out.connect(output);
this.voices = [];
}
setChord(notes, time) {
this.voices.forEach(v => {
v.gain.gain.setValueAtTime(v.gain.gain.value, time);
v.gain.gain.exponentialRampToValueAtTime(0.001, time + 5);
setTimeout(() => v.oscs.forEach(o => { try { o.stop(); } catch(e) {} }), 6000);
});
this.voices = [];
notes.forEach((midi, i) => this.voices.push(this.createVoice(midi, time, i)));
}
createVoice(midi, time, idx) {
const c = this.ctx;
const freq = mtof(midi);
const vGain = c.createGain();
vGain.gain.setValueAtTime(0, time);
vGain.gain.linearRampToValueAtTime(0.18, time + 3 + idx * 0.5);
vGain.connect(this.out);
const filt = c.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.value = this.engine.params.padFilter;
filt.Q.value = 0.7;
const lfo = c.createOscillator();
lfo.frequency.value = this.engine.params.padBreath + idx * 0.015;
const lfoG = c.createGain();
lfoG.gain.value = 400;
lfo.connect(lfoG);
lfoG.connect(filt.frequency);
lfo.start(time);
const lfo2 = c.createOscillator();
lfo2.frequency.value = 0.05 + idx * 0.02;
const pan = c.createStereoPanner();
const panLfoG = c.createGain();
panLfoG.gain.value = 0.4;
lfo2.connect(panLfoG);
panLfoG.connect(pan.pan);
lfo2.start(time);
filt.connect(pan);
pan.connect(vGain);
const oscs = [lfo, lfo2];
for (let i = 0; i < 6; i++) {
const osc = c.createOscillator();
osc.type = i < 4 ? 'sawtooth' : 'triangle';
osc.frequency.value = freq;
osc.detune.value = (i - 3) * 7 + Math.random() * 5;
const g = c.createGain();
g.gain.value = i < 4 ? 0.18 : 0.12;
osc.connect(g);
g.connect(filt);
osc.start(time);
oscs.push(osc);
}
const sub = c.createOscillator();
sub.type = 'sine';
sub.frequency.value = freq / 2;
const subG = c.createGain();
subG.gain.value = 0.15;
sub.connect(subG);
subG.connect(filt);
sub.start(time);
oscs.push(sub);
return { oscs, gain: vGain, lfo, filter: filt };
}
fadeOut() {
const t = this.ctx.currentTime;
this.voices.forEach(v => {
v.gain.gain.setValueAtTime(v.gain.gain.value, t);
v.gain.gain.exponentialRampToValueAtTime(0.001, t + 4);
});
}
updateParams() {
this.voices.forEach(v => {
if (v.filter) v.filter.frequency.value = this.engine.params.padFilter;
if (v.lfo) v.lfo.frequency.value = this.engine.params.padBreath;
});
}
}
class IndustrialClanks {
constructor(ctx, output, engine) {
this.ctx = ctx;
this.engine = engine;
this.out = ctx.createGain();
this.out.gain.value = 0.06;
this.out.connect(output);
}
play(time) {
const types = ['metal', 'thud', 'scrape', 'ring', 'clatter', 'pipe'];
const type = types[Math.floor(Math.random() * types.length)];
this['_' + type](time);
}
_metal(time) {
const c = this.ctx;
const freq = 180 + Math.random() * 350;
const car = c.createOscillator();
const mod = c.createOscillator();
const modG = c.createGain();
car.frequency.value = freq;
mod.frequency.value = freq * (1.4 + Math.random() * 2.5);
modG.gain.value = freq * (1 + Math.random() * 2);
mod.connect(modG);
modG.connect(car.frequency);
const env = c.createGain();
env.gain.setValueAtTime(0.6, time);
env.gain.exponentialRampToValueAtTime(0.001, time + 0.3);
const hp = c.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 400 + Math.random() * 400;
car.connect(hp);
hp.connect(env);
env.connect(this.out);
car.start(time);
mod.start(time);
car.stop(time + 1);
mod.stop(time + 1);
}
_thud(time) {
const c = this.ctx;
const osc = c.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(80, time);
osc.frequency.exponentialRampToValueAtTime(25, time + 0.25);
const env = c.createGain();
env.gain.setValueAtTime(0.9, time);
env.gain.exponentialRampToValueAtTime(0.001, time + 0.35);
const noise = this.makeNoise(0.15);
const nFilt = c.createBiquadFilter();
nFilt.type = 'lowpass';
nFilt.frequency.value = 150;
const nEnv = c.createGain();
nEnv.gain.setValueAtTime(0.4, time);
nEnv.gain.exponentialRampToValueAtTime(0.001, time + 0.12);
noise.connect(nFilt);
nFilt.connect(nEnv);
nEnv.connect(this.out);
osc.connect(env);
env.connect(this.out);
osc.start(time);
noise.start(time);
osc.stop(time + 0.5);
noise.stop(time + 0.2);
}
_scrape(time) {
const c = this.ctx;
const dur = 0.4 + Math.random() * 0.8;
const noise = this.makeNoise(dur);
const filt = c.createBiquadFilter();
filt.type = 'bandpass';
filt.Q.value = 15 + Math.random() * 10;
filt.frequency.setValueAtTime(2000 + Math.random() * 4000, time);
filt.frequency.exponentialRampToValueAtTime(400 + Math.random() * 600, time + dur);
const env = c.createGain();
env.gain.setValueAtTime(0, time);
env.gain.linearRampToValueAtTime(0.35, time + 0.03);
env.gain.exponentialRampToValueAtTime(0.001, time + dur);
noise.connect(filt);
filt.connect(env);
env.connect(this.out);
noise.start(time);
noise.stop(time + dur + 0.1);
}
_ring(time) {
const c = this.ctx;
const dur = 2 + Math.random() * 4;
const freqs = [200, 317, 438, 563, 712, 891].map(f => f * (0.8 + Math.random() * 0.4));
const master = c.createGain();
master.gain.setValueAtTime(0.25, time);
master.gain.exponentialRampToValueAtTime(0.001, time + dur);
master.connect(this.out);
freqs.forEach((f, i) => {
const osc = c.createOscillator();
osc.type = 'sine';
osc.frequency.value = f;
const g = c.createGain();
g.gain.value = 0.3 / (i + 1);
osc.connect(g);
g.connect(master);
osc.start(time);
osc.stop(time + dur + 0.1);
});
}
_clatter(time) {
const c = this.ctx;
const hits = 3 + Math.floor(Math.random() * 5);
for (let i = 0; i < hits; i++) {
const t = time + i * (0.02 + Math.random() * 0.05);
const freq = 300 + Math.random() * 600;
const osc = c.createOscillator();
const osc2 = c.createOscillator();
osc.frequency.value = freq;
osc2.frequency.value = freq * 1.7;
const env = c.createGain();
env.gain.setValueAtTime(0.15, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.06);
const hp = c.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 800;
osc.connect(hp);
osc2.connect(hp);
hp.connect(env);
env.connect(this.out);
osc.start(t);
osc2.start(t);
osc.stop(t + 0.1);
osc2.stop(t + 0.1);
}
}
_pipe(time) {
const c = this.ctx;
const freq = 60 + Math.random() * 80;
const dur = 1 + Math.random() * 2;
const osc = c.createOscillator();
osc.type = 'sine';
osc.frequency.value = freq;
const osc2 = c.createOscillator();
osc2.type = 'sine';
osc2.frequency.value = freq * 2.02;
const filt = c.createBiquadFilter();
filt.type = 'bandpass';
filt.frequency.value = freq * 3;
filt.Q.value = 5;
const env = c.createGain();
env.gain.setValueAtTime(0.5, time);
env.gain.exponentialRampToValueAtTime(0.3, time + 0.1);
env.gain.exponentialRampToValueAtTime(0.001, time + dur);
osc.connect(filt);
osc2.connect(filt);
filt.connect(env);
env.connect(this.out);
osc.start(time);
osc2.start(time);
osc.stop(time + dur + 0.1);
osc2.stop(time + dur + 0.1);
}
makeNoise(dur) {
const c = this.ctx;
const buf = c.createBuffer(1, c.sampleRate * dur, c.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
const src = c.createBufferSource();
src.buffer = buf;
return src;
}
}
class TriangleMelody {
constructor(ctx, output, engine) {
this.ctx = ctx;
this.engine = engine;
this.out = ctx.createGain();
this.out.gain.value = 0.09;
this.out.connect(output);
this.melody = [];
this.idx = 0;
this.osc = null;
this.currentFreq = mtof(ROOT + 12);
}
generateMelody() {
this.melody = [];
const len = 6 + Math.floor(Math.random() * 6);
for (let i = 0; i < len; i++) {
if (Math.random() > 0.25) {
const note = SCALE[Math.floor(Math.random() * 10)] + 12;
this.melody.push(ROOT + note);
} else {
this.melody.push(null);
}
}
}
start() {
const c = this.ctx;
this.generateMelody();
this.osc = c.createOscillator();
this.osc.type = 'triangle';
this.osc.frequency.value = this.currentFreq;
this.vib = c.createOscillator();
this.vib.frequency.value = this.engine.params.melodyVibrato;
this.vibG = c.createGain();
this.vibG.gain.value = 4;
this.vib.connect(this.vibG);
this.vibG.connect(this.osc.frequency);
this.vib.start();
this.filt = c.createBiquadFilter();
this.filt.type = 'lowpass';
this.filt.frequency.value = 2500;
this.filt.Q.value = 1;
this.oscGain = c.createGain();
this.oscGain.gain.value = 0;
this.osc.connect(this.filt);
this.filt.connect(this.oscGain);
this.oscGain.connect(this.out);
this.osc.start();
}
playNote(time) {
const note = this.melody[this.idx];
if (note !== null) {
const freq = mtof(note);
const slide = this.engine.params.melodySlide;
this.osc.frequency.setValueAtTime(this.currentFreq, time);
this.osc.frequency.linearRampToValueAtTime(freq, time + slide);
this.currentFreq = freq;
this.oscGain.gain.setValueAtTime(this.oscGain.gain.value, time);
this.oscGain.gain.linearRampToValueAtTime(0.7, time + 0.4);
this.oscGain.gain.linearRampToValueAtTime(0.25, time + 3);
} else {
this.oscGain.gain.setValueAtTime(this.oscGain.gain.value, time);
this.oscGain.gain.linearRampToValueAtTime(0, time + 1.5);
}
this.idx = (this.idx + 1) % this.melody.length;
if (this.idx === 0 && Math.random() > 0.4) this.generateMelody();
}
updateParams() {
if (this.vib) this.vib.frequency.value = this.engine.params.melodyVibrato;
}
stop() {
if (!this.osc) return;
const t = this.ctx.currentTime;
this.oscGain.gain.setValueAtTime(this.oscGain.gain.value, t);
this.oscGain.gain.linearRampToValueAtTime(0, t + 3);
this.osc.stop(t + 3.5);
this.vib.stop(t + 3.5);
}
}
// ==================== INIT ====================
const engine = new AmbientEngine();
initSequencer();
// Transport
const transportBtn = document.getElementById('transport');
transportBtn.addEventListener('click', async () => {
if (!engine.ctx) await engine.init();
if (engine.ctx.state === 'suspended') await engine.ctx.resume();
if (engine.playing) {
engine.stop();
transportBtn.textContent = '▶ START';
transportBtn.classList.remove('playing');
} else {
engine.start();
transportBtn.textContent = '■ STOP';
transportBtn.classList.add('playing');
}
});
// Knobs
const knobs = {
arpFilter: new Knob(document.getElementById('knob-arp-filter'), {
min: 200, max: 8000, initial: 2400,
valueEl: document.getElementById('val-arp-filter'),
format: v => v >= 1000 ? (v/1000).toFixed(1) + 'k' : Math.round(v),
onChange: v => { engine.params.arpFilter = v; }
}),
arpRes: new Knob(document.getElementById('knob-arp-res'), {
min: 0.5, max: 15, initial: 4,
valueEl: document.getElementById('val-arp-res'),
format: v => v.toFixed(1),
onChange: v => { engine.params.arpRes = v; }
}),
padFilter: new Knob(document.getElementById('knob-pad-filter'), {
min: 200, max: 4000, initial: 1200,
valueEl: document.getElementById('val-pad-filter'),
format: v => v >= 1000 ? (v/1000).toFixed(1) + 'k' : Math.round(v),
onChange: v => { engine.params.padFilter = v; engine.pad?.updateParams(); }
}),
padBreath: new Knob(document.getElementById('knob-pad-breath'), {
min: 0.02, max: 0.3, initial: 0.08,
valueEl: document.getElementById('val-pad-breath'),
format: v => v.toFixed(2),
onChange: v => { engine.params.padBreath = v; engine.pad?.updateParams(); }
}),
clankDensity: new Knob(document.getElementById('knob-clank-density'), {
min: 0, max: 0.15, initial: 0.035,
valueEl: document.getElementById('val-clank-density'),
format: v => Math.round(v * 100 / 0.15) + '%',
onChange: v => { engine.params.clankDensity = v; }
}),
melodySlide: new Knob(document.getElementById('knob-melody-slide'), {
min: 0.05, max: 0.8, initial: 0.25,
valueEl: document.getElementById('val-melody-slide'),
format: v => v.toFixed(2) + 's',
onChange: v => { engine.params.melodySlide = v; }
}),
melodyVibrato: new Knob(document.getElementById('knob-melody-vibrato'), {
min: 1, max: 12, initial: 4.5,
valueEl: document.getElementById('val-melody-vibrato'),
format: v => v.toFixed(1),
onChange: v => { engine.params.melodyVibrato = v; engine.melody?.updateParams(); }
})
};
// Trigger button
const triggerBtn = document.getElementById('clank-trigger');
triggerBtn.addEventListener('mousedown', () => { triggerBtn.classList.add('active'); engine.triggerClank(); });
triggerBtn.addEventListener('touchstart', e => { e.preventDefault(); triggerBtn.classList.add('active'); engine.triggerClank(); });
triggerBtn.addEventListener('mouseup', () => triggerBtn.classList.remove('active'));
triggerBtn.addEventListener('touchend', () => triggerBtn.classList.remove('active'));
triggerBtn.addEventListener('mouseleave', () => triggerBtn.classList.remove('active'));
// Faders
const faders = {};
['arp', 'pad', 'clank', 'melody'].forEach(ch => {
faders[ch] = new Fader(document.getElementById(`fader-${ch}`), {
min: 0, max: 1.2, initial: 0.8,
dbEl: document.getElementById(`db-${ch}`),
onChange: v => {
engine.mixer[ch].vol = v;
engine.updateChannel(ch);
}
});
});
// Mute/Solo
document.querySelectorAll('.mute-btn').forEach(btn => {
btn.addEventListener('click', () => {
const ch = btn.dataset.ch;
engine.mixer[ch].mute = !engine.mixer[ch].mute;
btn.classList.toggle('active', engine.mixer[ch].mute);
engine.updateAllChannels();
});
});
document.querySelectorAll('.solo-btn').forEach(btn => {
btn.addEventListener('click', () => {
const ch = btn.dataset.ch;
engine.mixer[ch].solo = !engine.mixer[ch].solo;
btn.classList.toggle('active', engine.mixer[ch].solo);
engine.updateAllChannels();
});
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment