Skip to content

Instantly share code, notes, and snippets.

@nsdevaraj
Created June 23, 2026 09:40
Show Gist options
  • Select an option

  • Save nsdevaraj/c8607720378a5e2e0567e8415faab8c5 to your computer and use it in GitHub Desktop.

Select an option

Save nsdevaraj/c8607720378a5e2e0567e8415faab8c5 to your computer and use it in GitHub Desktop.
This browser-based studio combines step sequencing, audio recording, and live performance tools. Multi‑Track Recording
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BandLab Studio — Multi‑Track DAW</title>
<script src="https://cdn.tailwindcss.com">
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz@14..32&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: #0b0a0e;
color: #eeeef0;
min-height: 100vh;
overflow-x: hidden;
}
.glass {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.glass-strong {
background: rgba(20, 18, 26, 0.72);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.07);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 12px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.audio-slider {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
outline: none;
transition: background 0.2s;
}
.audio-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
cursor: pointer;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.15);
}
.audio-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
cursor: pointer;
border: none;
}
.rec-pulse {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.waveform-canvas {
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
}
.track-row:hover .track-controls {
opacity: 1;
}
.track-controls {
opacity: 0.6;
transition: opacity 0.2s;
}
.step-cell {
transition: all 0.08s ease;
}
.step-cell-on {
box-shadow: 0 0 16px -4px var(--accent-glow, #fff3);
}
select option {
background: #1a1822;
color: #eeeef0;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
padding: 0 12px;
height: 36px;
font-size: 0.85rem;
font-weight: 500;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.06);
color: #ddd;
cursor: pointer;
transition: all 0.15s;
gap: 6px;
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
}
.btn-icon:active {
transform: scale(0.96);
}
.btn-primary {
background: linear-gradient(135deg, #d97a3a, #c55a8a);
border-color: transparent;
color: #fff;
}
.btn-primary:hover {
box-shadow: 0 0 30px rgba(217, 122, 58, 0.25);
}
.btn-danger {
background: rgba(220, 60, 70, 0.7);
border-color: transparent;
color: #fff;
}
.btn-danger:hover {
background: rgba(220, 60, 70, 0.9);
}
.btn-success {
background: rgba(40, 200, 120, 0.6);
border-color: transparent;
color: #fff;
}
.btn-success:hover {
background: rgba(40, 200, 120, 0.8);
}
.rec-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #e34;
display: inline-block;
}
.rec-dot.active {
animation: pulse 0.8s ease-in-out infinite;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bg-grid {
background-image: radial-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 32px 32px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.0.0",
"react-dom/client": "https://esm.sh/react-dom@19.0.0/client",
"lucide-react": "https://esm.sh/lucide-react@0.468.0"
}
}
</script>
<script type="module">
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
createContext,
useContext,
forwardRef,
useImperativeHandle,
} from 'react';
import { createRoot } from 'react-dom/client';
import {
Play,
Square,
Mic,
MicOff,
Plus,
Trash2,
Volume2,
VolumeX,
Download,
Upload,
ArrowLeft,
Waves,
Music4,
ChevronDown,
Shuffle,
Eraser,
Keyboard,
Radio,
Move,
Pause,
RotateCcw,
ListMusic,
X,
Sliders,
Maximize2,
Minimize2,
} from 'lucide-react';
// ──────────────────────────────────────────────
// CONSTANTS & HELPERS (pure JS)
// ──────────────────────────────────────────────
const STEP_COUNT = 16;
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const SCALES = {
Major: [0, 2, 4, 5, 7, 9, 11],
Minor: [0, 2, 3, 5, 7, 8, 10],
Dorian: [0, 2, 3, 5, 7, 9, 10],
Pentatonic: [0, 3, 5, 7, 10],
'Harmonic Minor': [0, 2, 3, 5, 7, 8, 11],
};
const DRUM_NAMES = {
kick: 'Kick',
snare: 'Snare',
clap: 'Clap',
hihat: 'Hi-Hat',
openhat: 'Open Hat',
tom: 'Tom',
rim: 'Rim',
cowbell: 'Cowbell',
};
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function stepsFrom(indices) {
const arr = new Array(STEP_COUNT).fill(false);
for (const i of indices)
if (i >= 0 && i < STEP_COUNT) arr[i] = true;
return arr;
}
const COLORS = {
kick: { color: 'from-[oklch(0.65_0.24_25)] to-[oklch(0.7_0.22_55)]',
accent: 'oklch(0.66 0.24 25)' },
snare: { color: 'from-[oklch(0.65_0.24_350)] to-[oklch(0.55_0.22_300)]',
accent: 'oklch(0.66 0.24 350)' },
clap: { color: 'from-[oklch(0.7_0.22_55)] to-[oklch(0.75_0.18_60)]',
accent: 'oklch(0.72 0.2 55)' },
hihat: { color: 'from-[oklch(0.7_0.18_180)] to-[oklch(0.7_0.18_200)]',
accent: 'oklch(0.72 0.16 195)' },
openhat: { color: 'from-[oklch(0.75_0.18_60)] to-[oklch(0.7_0.18_180)]',
accent: 'oklch(0.78 0.16 75)' },
tom: { color: 'from-[oklch(0.55_0.22_300)] to-[oklch(0.65_0.24_350)]',
accent: 'oklch(0.6 0.22 300)' },
bass: { color: 'from-[oklch(0.6_0.22_350)] to-[oklch(0.55_0.22_300)]',
accent: 'oklch(0.62 0.2 330)' },
lead: { color: 'from-[oklch(0.7_0.18_200)] to-[oklch(0.7_0.18_180)]',
accent: 'oklch(0.74 0.16 205)' },
audio: { color: 'from-[oklch(0.5_0.2_280)] to-[oklch(0.6_0.22_320)]',
accent: 'oklch(0.55 0.2 300)' },
};
const PATTERNS = {
'Boom Bap': {
kick: [0, 3, 8, 11],
snare: [4, 12],
hihat: [0, 2, 4, 6, 8, 10, 12, 14],
openhat: [14],
clap: [12],
bass: [0, 3, 6, 8, 11, 14],
lead: [0, 4, 7, 8, 12, 15],
},
House: {
kick: [0, 4, 8, 12],
clap: [4, 12],
hihat: [2, 6, 10, 14],
openhat: [2, 6, 10, 14],
bass: [0, 2, 4, 6, 8, 10, 12, 14],
lead: [0, 3, 6, 10, 13],
},
Trap: {
kick: [0, 6, 10],
snare: [8],
hihat: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15],
openhat: [11],
bass: [0, 6, 10],
lead: [0, 4, 8, 11, 14],
},
'Four on the Floor': {
kick: [0, 4, 8, 12],
snare: [4, 12],
clap: [4, 12],
hihat: [2, 6, 10, 14],
tom: [15],
bass: [0, 2, 4, 6, 8, 10, 12, 14],
lead: [0, 2, 4, 6, 8, 10, 12, 14],
},
'Lo-Fi': {
kick: [0, 7, 10],
snare: [4, 12],
hihat: [0, 3, 6, 8, 11, 14],
bass: [0, 7, 10],
lead: [2, 6, 9, 13],
},
Empty: {},
};
function buildStepTrack(id, name, drum, waveform, octave, arp, pattern) {
const c = drum ? COLORS[drum] : COLORS.lead;
return {
id,
name,
kind: 'step',
drum: drum || undefined,
waveform: waveform || undefined,
octave: octave || 3,
arp: arp || false,
steps: stepsFrom(pattern || []),
volume: 0.8,
muted: false,
solo: false,
color: c.color,
accent: c.accent,
};
}
function createAudioTrack(id, name) {
const c = COLORS.audio;
return {
id,
name: name || 'Audio Track',
kind: 'audio',
buffer: null,
blobUrl: null,
volume: 0.85,
muted: false,
solo: false,
pan: 0,
color: c.color,
accent: c.accent,
isRecording: false,
duration: 0,
};
}
function defaultProject() {
const p = PATTERNS['Boom Bap'];
return {
name: 'My Project',
bpm: 92,
swing: 0.18,
masterVolume: 0.85,
keyRoot: 9,
scale: 'Minor',
tracks: [
buildStepTrack('kick', 'Kick', 'kick', null, 3, false, p.kick || []),
buildStepTrack('snare', 'Snare', 'snare', null, 3, false, p.snare || []),
buildStepTrack('hihat', 'Hi-Hat', 'hihat', null, 3, false, p.hihat || []),
buildStepTrack('bass', 'Bass', null, 'sawtooth', 3, true, p.bass || []),
buildStepTrack('lead', 'Lead', null, 'square', 5, true, p.lead || []),
createAudioTrack('audio-1', 'Vocal'),
],
};
}
// ──────────────────────────────────────────────
// AUDIO ENGINE (pure JS)
// ──────────────────────────────────────────────
class AudioEngine {
constructor() {
this.ctx = null;
this.master = null;
this.limiter = null;
this.noiseBuffer = null;
this.bpm = 92;
this.masterVolume = 0.85;
this.running = false;
this.currentStep = 0;
this.nextNoteTime = 0;
this.timer = null;
this.notesInQueue = [];
this.hooks = null;
this.voices = new Map();
this.audioPlaybacks = new Map();
this.mediaRecorder = null;
this.recordingStream = null;
this.recordingChunks = [];
this.onRecordingData = null;
}
setHooks(h) { this.hooks = h; }
init() {
if (this.ctx) return;
const Ctor = window.AudioContext || window.webkitAudioContext;
const ctx = new Ctor();
this.ctx = ctx;
this.limiter = ctx.createDynamicsCompressor();
this.limiter.threshold.value = -6;
this.limiter.knee.value = 6;
this.limiter.ratio.value = 12;
this.limiter.attack.value = 0.003;
this.limiter.release.value = 0.18;
this.master = ctx.createGain();
this.master.gain.value = this.masterVolume;
this.master.connect(this.limiter);
this.limiter.connect(ctx.destination);
this.noiseBuffer = this.makeNoise(ctx);
}
makeNoise(ctx) {
const len = Math.floor(ctx.sampleRate * 0.5);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
return buf;
}
resume() { if (this.ctx) this.ctx.resume(); }
setBpm(b) { this.bpm = b; }
setMasterVolume(v) {
this.masterVolume = v;
if (this.master && this.ctx) {
this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.02);
}
}
start() {
if (!this.ctx) this.init();
const ctx = this.ctx;
if (!ctx) return;
ctx.resume();
if (this.running) return;
this.running = true;
this.currentStep = 0;
this.notesInQueue = [];
this.nextNoteTime = ctx.currentTime + 0.08;
this.scheduler();
}
stop() {
this.running = false;
if (this.timer) { clearTimeout(this.timer);
this.timer = null; }
this.notesInQueue = [];
this.currentStep = 0;
for (const [id, pb] of this.audioPlaybacks) {
try { pb.source.stop(); } catch (_) {}
}
this.audioPlaybacks.clear();
for (const [midi, v] of this.voices) {
try { v.oscs.forEach(o => o.stop()); } catch (_) {}
}
this.voices.clear();
}
dispose() {
this.stop();
if (this.ctx) this.ctx.close();
this.ctx = null;
this.master = null;
this.limiter = null;
}
stepDuration() {
return 60 / this.bpm / 4;
}
scheduler = () => {
const ctx = this.ctx;
if (!ctx || !this.hooks || !this.running) return;
const stepDur = this.stepDuration();
const swing = this.hooks.getSwing();
const stepCount = this.hooks.getStepCount();
const tracks = this.hooks.getTracks();
while (this.nextNoteTime < ctx.currentTime + 0.12) {
const isOffBeat = this.currentStep % 2 === 1;
const time = isOffBeat ?
this.nextNoteTime + swing * stepDur * 0.5 :
this.nextNoteTime;
this.scheduleStep(this.currentStep, time);
this.nextNoteTime += stepDur;
this.currentStep = (this.currentStep + 1) % stepCount;
}
this.timer = setTimeout(this.scheduler, 25);
};
scheduleStep(step, time) {
this.notesInQueue.push({ step, time });
const tracks = this.hooks.getTracks();
const soloed = tracks.some(t => t.solo);
const accent = step % 4 === 0 ? 1 : step % 2 === 0 ? 0.86 : 0.72;
const stepDur = this.stepDuration();
for (const t of tracks) {
if (t.kind === 'audio') continue;
if (!t.steps[step]) continue;
const audible = soloed ? t.solo : !t.muted;
if (!audible) continue;
if (t.drum) {
this.playDrum(t.drum, time, t.volume * accent);
} else {
const midi = this.hooks.getMelodyForStep(t, step);
if (midi != null) {
this.playSynth(midi, time, stepDur * 0.92,
t.waveform || 'sawtooth',
t.volume * 0.5 * accent);
}
}
}
}
playSynth(midi, time, duration, waveform, level) {
const ctx = this.ctx;
if (!ctx) return;
const freq = midiToFreq(midi);
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(Math.min(9000, freq * 8), time);
filter.frequency.exponentialRampToValueAtTime(
Math.max(400, freq * 2), time + duration
);
const o1 = ctx.createOscillator();
o1.type = waveform;
o1.frequency.value = freq;
const o2 = ctx.createOscillator();
o2.type = waveform;
o2.frequency.value = freq;
o2.detune.value = 7;
const a = 0.005,
d = 0.05,
s = 0.6,
r = 0.09;
gain.gain.setValueAtTime(0.0001, time);
gain.gain.linearRampToValueAtTime(level, time + a);
gain.gain.linearRampToValueAtTime(level * s, time + a + d);
gain.gain.setValueAtTime(level * s, time + duration);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration + r);
o1.connect(filter);
o2.connect(filter);
filter.connect(gain);
gain.connect(this.master);
const end = time + duration + r + 0.02;
o1.start(time);
o2.start(time);
o1.stop(end);
o2.stop(end);
}
playDrum(type, time, level) {
switch (type) {
case 'kick':
return this.kick(time, level);
case 'snare':
return this.snare(time, level);
case 'clap':
return this.clap(time, level);
case 'hihat':
return this.hat(time, level, false);
case 'openhat':
return this.hat(time, level, true);
case 'tom':
return this.tom(time, level);
case 'rim':
return this.rim(time, level);
case 'cowbell':
return this.cowbell(time, level);
}
}
kick(time, level) {
const ctx = this.ctx;
if (!ctx) return;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(160, time);
o.frequency.exponentialRampToValueAtTime(50, time + 0.11);
g.gain.setValueAtTime(Math.min(1, level), time);
g.gain.exponentialRampToValueAtTime(0.0001, time + 0.42);
o.connect(g);
g.connect(this.master);
o.start(time);
o.stop(time + 0.45);
}
snare(time, level) {
const ctx = this.ctx;
if (!ctx) return;
const noise = ctx.createBufferSource();
noise.buffer = this.noiseBuffer;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 1200;
const ng = ctx.createGain();
ng.gain.setValueAtTime(level * 0.8, time);
ng.gain.exponentialRampToValueAtTime(0.0001, time + 0.18);
noise.connect(hp);
hp.connect(ng);
ng.connect(this.master);
noise.start(time);
noise.stop(time + 0.2);
const o = ctx.createOscillator();
o.type = 'triangle';
o.frequency.setValueAtTime(190, time);
const og = ctx.createGain();
og.gain.setValueAtTime(level * 0.5, time);
og.gain.exponentialRampToValueAtTime(0.0001, time + 0.11);
o.connect(og);
og.connect(this.master);
o.start(time);
o.stop(time + 0.12);
}
hat(time, level, open) {
const ctx = this.ctx;
if (!ctx) return;
const noise = ctx.createBufferSource();
noise.buffer = this.noiseBuffer;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 7000;
const g = ctx.createGain();
const dur = open ? 0.32 : 0.06;
g.gain.setValueAtTime(level * 0.5, time);
g.gain.exponentialRampToValueAtTime(0.0001, time + dur);
noise.connect(hp);
hp.connect(g);
g.connect(this.master);
noise.start(time);
noise.stop(time + dur + 0.02);
}
clap(time, level) {
const ctx = this.ctx;
if (!ctx) return;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1100;
bp.Q.value = 1.3;
const out = ctx.createGain();
out.gain.setValueAtTime(level * 0.7, time);
out.gain.exponentialRampToValueAtTime(0.0001, time + 0.2);
bp.connect(out);
out.connect(this.master);
for (const off of [0, 0.012, 0.024, 0.04]) {
const n = ctx.createBufferSource();
n.buffer = this.noiseBuffer;
const g = ctx.createGain();
g.gain.setValueAtTime(1, time + off);
g.gain.exponentialRampToValueAtTime(0.001, time + off + 0.05);
n.connect(g);
g.connect(bp);
n.start(time + off);
n.stop(time + off + 0.06);
}
}
tom(time, level) {
const ctx = this.ctx;
if (!ctx) return;
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(200, time);
o.frequency.exponentialRampToValueAtTime(90, time + 0.25);
const g = ctx.createGain();
g.gain.setValueAtTime(level * 0.9, time);
g.gain.exponentialRampToValueAtTime(0.0001, time + 0.3);
o.connect(g);
g.connect(this.master);
o.start(time);
o.stop(time + 0.32);
}
rim(time, level) {
const ctx = this.ctx;
if (!ctx) return;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = 1700;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1700;
bp.Q.value = 6;
const g = ctx.createGain();
g.gain.setValueAtTime(level * 0.7, time);
g.gain.exponentialRampToValueAtTime(0.0001, time + 0.05);
o.connect(bp);
bp.connect(g);
g.connect(this.master);
o.start(time);
o.stop(time + 0.06);
}
cowbell(time, level) {
const ctx = this.ctx;
if (!ctx) return;
const mk = (f) => {
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = f;
return o;
};
const o1 = mk(560),
o2 = mk(845);
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 700;
bp.Q.value = 2;
const g = ctx.createGain();
g.gain.setValueAtTime(level * 0.5, time);
g.gain.exponentialRampToValueAtTime(0.0001, time + 0.25);
o1.connect(bp);
o2.connect(bp);
bp.connect(g);
g.connect(this.master);
o1.start(time);
o2.start(time);
o1.stop(time + 0.26);
o2.stop(time + 0.26);
}
previewDrum(type) {
if (!this.ctx) this.init();
if (!this.ctx) return;
this.ctx.resume();
this.playDrum(type, this.ctx.currentTime + 0.01, 0.9);
}
previewNote(midi, waveform) {
if (!this.ctx) this.init();
if (!this.ctx) return;
this.ctx.resume();
this.playSynth(midi, this.ctx.currentTime + 0.01, 0.35, waveform || 'sawtooth', 0.4);
}
playAudioBuffer(trackId, buffer, volume, pan, startTime) {
const ctx = this.ctx;
if (!ctx) return;
this.stopAudioBuffer(trackId);
const src = ctx.createBufferSource();
src.buffer = buffer;
const gain = ctx.createGain();
gain.gain.value = volume;
const panner = ctx.createStereoPanner();
panner.pan.value = pan || 0;
src.connect(gain);
gain.connect(panner);
panner.connect(this.master);
const t = startTime || ctx.currentTime;
src.start(t);
this.audioPlaybacks.set(trackId, { source: src, gain, startedAt: t });
}
stopAudioBuffer(trackId) {
const pb = this.audioPlaybacks.get(trackId);
if (pb) {
try { pb.source.stop(); } catch (_) {}
this.audioPlaybacks.delete(trackId);
}
}
async startRecording(onData) {
this.onRecordingData = onData;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.recordingStream = stream;
const recorder = new MediaRecorder(stream);
this.recordingChunks = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.recordingChunks.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(this.recordingChunks, { type: 'audio/webm' });
if (this.onRecordingData) this.onRecordingData(blob);
this.recordingChunks = [];
};
recorder.start(100);
this.mediaRecorder = recorder;
return true;
} catch (e) {
console.error('Recording failed:', e);
return false;
}
}
stopRecording() {
return new Promise((resolve) => {
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
resolve(null);
return;
}
const onStop = () => {
this.mediaRecorder.removeEventListener('stop', onStop);
const blob = new Blob(this.recordingChunks, { type: 'audio/webm' });
this.recordingChunks = [];
if (this.recordingStream) {
this.recordingStream.getTracks().forEach(t => t.stop());
this.recordingStream = null;
}
this.mediaRecorder = null;
resolve(blob);
};
this.mediaRecorder.addEventListener('stop', onStop);
this.mediaRecorder.stop();
});
}
async loadAudioFile(file) {
if (!this.ctx) this.init();
const ctx = this.ctx;
if (!ctx) return null;
try {
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
return audioBuffer;
} catch (e) {
console.error('Failed to load audio:', e);
return null;
}
}
noteOn(midi, waveform, level) {
if (!this.ctx) this.init();
const ctx = this.ctx;
if (!ctx) return;
ctx.resume();
if (this.voices.has(midi)) this.noteOff(midi);
const now = ctx.currentTime;
const freq = midiToFreq(midi);
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = Math.min(9000, freq * 7);
const o1 = ctx.createOscillator();
o1.type = waveform || 'square';
o1.frequency.value = freq;
const o2 = ctx.createOscillator();
o2.type = waveform || 'square';
o2.frequency.value = freq;
o2.detune.value = 8;
gain.gain.setValueAtTime(0.0001, now);
gain.gain.linearRampToValueAtTime(level || 0.38, now + 0.008);
gain.gain.linearRampToValueAtTime((level || 0.38) * 0.7, now + 0.12);
o1.connect(filter);
o2.connect(filter);
filter.connect(gain);
gain.connect(this.master);
o1.start(now);
o2.start(now);
this.voices.set(midi, { gain, oscs: [o1, o2] });
}
noteOff(midi) {
const v = this.voices.get(midi);
if (!v || !this.ctx) return;
const now = this.ctx.currentTime;
v.gain.gain.cancelScheduledValues(now);
v.gain.gain.setValueAtTime(Math.max(0.0001, v.gain.gain.value), now);
v.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.14);
v.oscs.forEach(o => o.stop(now + 0.16));
this.voices.delete(midi);
}
}
// ──────────────────────────────────────────────
// REACT COMPONENTS
// ──────────────────────────────────────────────
const StudioContext = createContext(null);
function useStudio() {
const c = useContext(StudioContext);
if (!c) throw new Error('useStudio must be used within StudioProvider');
return c;
}
// ── provider ──
function StudioProvider({ children }) {
const [project, setProject] = useState(defaultProject);
const [isPlaying, setIsPlaying] = useState(false);
const [currentStep, setCurrentStep] = useState(-1);
const [engine, setEngine] = useState(null);
const engineRef = useRef(null);
const projectRef = useRef(project);
projectRef.current = project;
useEffect(() => {
const e = new AudioEngine();
e.setHooks({
getTracks: () => projectRef.current.tracks,
getSwing: () => projectRef.current.swing,
getStepCount: () => STEP_COUNT,
getMelodyForStep: (track, step) => {
if (track.kind !== 'step') return null;
const baseMidi = projectRef.current.keyRoot + 12 * (track.octave || 3);
if (!track.arp) return baseMidi;
const scale = SCALES[projectRef.current.scale];
let idx = 0;
for (let i = 0; i < step; i++)
if (track.steps[i]) idx++;
const span = scale.length * 2;
idx = idx % span;
const degree = idx % scale.length;
const octaveShift = Math.floor(idx / scale.length);
return baseMidi + scale[degree] + 12 * octaveShift;
},
});
engineRef.current = e;
setEngine(e);
return () => e.dispose();
}, []);
useEffect(() => {
engineRef.current?.setBpm(project.bpm);
}, [project.bpm]);
useEffect(() => {
engineRef.current?.setMasterVolume(project.masterVolume);
}, [project.masterVolume]);
useEffect(() => {
if (!isPlaying) { setCurrentStep(-1); return; }
let raf = 0;
let last = -1;
const draw = () => {
const e = engineRef.current;
const ctx = e?.ctx;
if (e && ctx) {
const now = ctx.currentTime;
let step = last;
while (e.notesInQueue.length && e.notesInQueue[0].time <= now) {
step = e.notesInQueue[0].step;
e.notesInQueue.shift();
}
if (step !== last) { last = step;
setCurrentStep(step); }
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [isPlaying]);
const togglePlay = useCallback(() => {
const e = engineRef.current;
if (!e) return;
if (isPlaying) {
e.stop();
setIsPlaying(false);
} else {
e.init();
e.setBpm(projectRef.current.bpm);
e.setMasterVolume(projectRef.current.masterVolume);
e.start();
setIsPlaying(true);
}
}, [isPlaying]);
useEffect(() => {
const onKey = (ev) => {
if (ev.code !== 'Space') return;
const tag = ev.target?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
ev.preventDefault();
togglePlay();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [togglePlay]);
const updateTrack = useCallback((id, partial) => {
setProject(prev => ({
...prev,
tracks: prev.tracks.map(t => t.id === id ? { ...t, ...partial } : t),
}));
}, []);
const removeTrack = useCallback((id) => {
setProject(prev => ({
...prev,
tracks: prev.tracks.filter(t => t.id !== id),
}));
}, []);
const addStepTrack = useCallback((name, drum) => {
const id = `step-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const track = buildStepTrack(id, name, drum, 'sawtooth', 3, false, []);
setProject(prev => ({ ...prev, tracks: [...prev.tracks, track] }));
}, []);
const addAudioTrack = useCallback((name) => {
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const track = createAudioTrack(id, name);
setProject(prev => ({ ...prev, tracks: [...prev.tracks, track] }));
}, []);
const toggleStep = useCallback((id, step) => {
setProject(prev => ({
...prev,
tracks: prev.tracks.map(t => {
if (t.id !== id || t.kind !== 'step') return t;
return {
...t,
steps: t.steps.map((on, i) => i === step ? !on : on),
};
}),
}));
}, []);
const previewTrack = useCallback((track) => {
const e = engineRef.current;
if (!e) return;
if (track.kind === 'step') {
if (track.drum) {
e.previewDrum(track.drum);
} else {
const midi = projectRef.current.keyRoot + 12 * (track.octave || 3);
e.previewNote(midi, track.waveform || 'sawtooth');
}
} else if (track.kind === 'audio' && track.buffer) {
e.playAudioBuffer(track.id, track.buffer, track.volume, track.pan || 0);
setTimeout(() => e.stopAudioBuffer(track.id), (track.buffer.duration || 1) * 1000 + 200);
}
}, []);
const loadPattern = useCallback((name) => {
const pattern = PATTERNS[name] || {};
setProject(prev => ({
...prev,
tracks: prev.tracks.map(t => {
if (t.kind !== 'step') return t;
const steps = pattern[t.id] || [];
return { ...t, steps: stepsFrom(steps) };
}),
}));
}, []);
const clearAll = useCallback(() => {
setProject(prev => ({
...prev,
tracks: prev.tracks.map(t => {
if (t.kind === 'step') return { ...t, steps: new Array(STEP_COUNT).fill(false) };
return t;
}),
}));
}, []);
const randomize = useCallback(() => {
const density = {
kick: 0.32,
snare: 0.16,
clap: 0.12,
hihat: 0.55,
openhat: 0.16,
tom: 0.12,
bass: 0.4,
lead: 0.4,
};
setProject(prev => ({
...prev,
tracks: prev.tracks.map(t => {
if (t.kind !== 'step') return t;
return {
...t,
steps: Array.from({ length: STEP_COUNT }, (_, i) => {
if (t.id === 'kick' && i === 0) return true;
return Math.random() < (density[t.id] || 0.25);
}),
};
}),
}));
}, []);
const exportProject = useCallback(() => {
const blob = new Blob([JSON.stringify(projectRef.current, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${projectRef.current.name.replace(/\s+/g, '-').toLowerCase()}.bandlab.json`;
a.click();
URL.revokeObjectURL(url);
}, []);
const importProject = useCallback(async (file) => {
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tracks || !Array.isArray(data.tracks)) throw new Error('Invalid project file');
engineRef.current?.stop();
setProject(data);
} catch (e) {
console.error('Import failed:', e);
alert('Failed to import project. Please check the file format.');
}
}, []);
const value = {
project,
setProject,
engine,
isPlaying,
setIsPlaying,
currentStep,
togglePlay,
updateTrack,
removeTrack,
addStepTrack,
addAudioTrack,
toggleStep,
previewTrack,
loadPattern,
clearAll,
randomize,
exportProject,
importProject,
};
return React.createElement(StudioContext.Provider, { value }, children);
}
// ── PianoKeyboard ──
const KEY_MAP = {
a: 0,
w: 1,
s: 2,
e: 3,
d: 4,
f: 5,
t: 6,
g: 7,
y: 8,
h: 9,
u: 10,
j: 11,
k: 12,
o: 13,
l: 14,
p: 15,
';': 16,
};
const BLACK_SET = new Set([1, 3, 6, 8, 10]);
function PianoKeyboard({ engine, startMidi = 60, octaves = 2, waveform = 'square' }) {
const [active, setActive] = useState(new Set());
const activeRef = useRef(active);
activeRef.current = active;
const press = useCallback((midi) => {
if (activeRef.current.has(midi)) return;
engine?.noteOn(midi, waveform, 0.38);
setActive(prev => new Set(prev).add(midi));
}, [engine, waveform]);
const release = useCallback((midi) => {
engine?.noteOff(midi);
setActive(prev => {
const next = new Set(prev);
next.delete(midi);
return next;
});
}, [engine]);
useEffect(() => {
const down = (e) => {
if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
const tag = e.target?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
const offset = KEY_MAP[e.key.toLowerCase()];
if (offset === undefined) return;
e.preventDefault();
press(startMidi + offset);
};
const up = (e) => {
const offset = KEY_MAP[e.key.toLowerCase()];
if (offset === undefined) return;
release(startMidi + offset);
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, [press, release, startMidi]);
const total = octaves * 12 + 1;
const notes = Array.from({ length: total }, (_, i) => startMidi + i);
const whiteNotes = notes.filter(m => !BLACK_SET.has(m % 12));
return React.createElement('div', { className: 'select-none' },
React.createElement('div', { className: 'relative h-32 sm:h-40 flex', style: { touchAction: 'none' } },
whiteNotes.map(midi => {
const isC = midi % 12 === 0;
return React.createElement('button', {
key: midi,
onPointerDown: (e) => { e.currentTarget.setPointerCapture(e.pointerId);
press(midi); },
onPointerUp: () => release(midi),
onPointerLeave: (e) => { if (e.buttons > 0) release(midi); },
onPointerEnter: (e) => { if (e.buttons > 0) press(midi); },
className: `relative flex-1 rounded-b-lg border border-white/10 border-t-0 transition-colors flex items-end justify-center pb-2 ${active.has(midi) ? 'bg-gradient-to-b from-[oklch(0.65_0.24_25)] to-[oklch(0.65_0.24_350)]' : 'bg-gradient-to-b from-white/85 to-white/70 hover:from-white hover:to-white/80'}`,
},
isC && React.createElement('span', {
className: `text-[10px] font-bold pointer-events-none ${active.has(midi) ? 'text-white' : 'text-black/40'}`,
}, `${NOTE_NAMES[0]}${Math.floor(midi / 12) - 1}`)
);
}),
React.createElement('div', { className: 'absolute inset-0 pointer-events-none' },
React.createElement('div', { className: 'relative h-full flex' },
whiteNotes.map((midi, i) => {
const hasBlack = BLACK_SET.has((midi + 1) % 12);
return React.createElement('div', { key: midi, className: 'relative flex-1' },
hasBlack && i < whiteNotes.length - 1 && React.createElement('button', {
onPointerDown: (e) => { e.currentTarget.setPointerCapture(e.pointerId);
press(midi + 1); },
onPointerUp: () => release(midi + 1),
onPointerLeave: (e) => { if (e.buttons > 0) release(midi + 1); },
className: `pointer-events-auto absolute z-10 top-0 h-[62%] w-[62%] -right-[31%] rounded-b-md border border-black/40 shadow-lg transition-colors ${active.has(midi + 1) ? 'bg-gradient-to-b from-[oklch(0.65_0.24_25)] to-[oklch(0.65_0.24_350)]' : 'bg-gradient-to-b from-[oklch(0.22_0.01_280)] to-black hover:from-[oklch(0.3_0.02_280)]'}`,
})
);
})
)
)
)
);
}
// ── Select ──
function Select({ value, onChange, options, placeholder }) {
return React.createElement('div', { className: 'relative' },
React.createElement('select', {
value: value,
onChange: e => onChange(e.target.value),
className: 'appearance-none glass hover:bg-white/10 rounded-lg h-9 pl-3 pr-8 text-sm font-medium cursor-pointer outline-none focus:ring-2 focus:ring-[oklch(0.65_0.24_25/50%)]',
},
placeholder && React.createElement('option', { value: '', disabled: true, hidden: true }, placeholder),
options.map(o => React.createElement('option', { key: o.value, value: o.value,
className: 'bg-[oklch(0.16_0.008_280)]' }, o.label))
),
React.createElement(ChevronDown, { className: 'h-4 w-4 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground' })
);
}
// ── Transport ──
function Transport() {
const { project, isPlaying, togglePlay, loadPattern, clearAll, randomize, exportProject, setProject } =
useStudio();
return React.createElement('div', { className: 'glass-strong rounded-2xl p-3 sm:p-4 flex flex-wrap items-center gap-3 sm:gap-4' },
React.createElement('button', { onClick: togglePlay,
className: `h-14 w-14 shrink-0 rounded-2xl flex items-center justify-center shadow-lg transition-transform hover:scale-105 active:scale-95 ${isPlaying ? 'bg-white/10 text-white' : 'bg-gradient-to-r from-[oklch(0.65_0.24_25)] to-[oklch(0.65_0.24_350)] text-white shadow-[oklch(0.65_0.24_25/40%)]'}` },
isPlaying ? React.createElement(Square, { className: 'h-6 w-6 fill-current' }) :
React.createElement(Play, { className: 'h-6 w-6 fill-current ml-0.5' })
),
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement('div', { className: 'text-center' },
React.createElement('div', { className: 'text-[10px] uppercase tracking-widest text-muted-foreground' },
'Tempo'),
React.createElement('div', { className: 'flex items-center gap-1.5' },
React.createElement('button', { onClick: () => setProject({ ...project, bpm: Math.max(40,
project.bpm - 1) }),
className: 'h-6 w-6 rounded-md glass hover:bg-white/10 text-sm font-bold leading-none' },
'−'),
React.createElement('div', { className: 'tabular-nums font-mono text-xl font-bold w-12 text-center' },
project.bpm),
React.createElement('button', { onClick: () => setProject({ ...project, bpm: Math.min(220,
project.bpm + 1) }),
className: 'h-6 w-6 rounded-md glass hover:bg-white/10 text-sm font-bold leading-none' },
'+')
)
),
React.createElement('input', { type: 'range', min: 40, max: 220, value: project.bpm,
onChange: e => setProject({ ...project, bpm: Number(e.target.value) }),
className: 'audio-slider w-24 hidden md:block' })
),
React.createElement('div', { className: 'h-10 w-px bg-white/10 hidden sm:block' }),
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement(Music4, { className: 'h-4 w-4 text-muted-foreground' }),
React.createElement(Select, { value: String(project.keyRoot),
onChange: v => setProject({ ...project, keyRoot: Number(v) }),
options: NOTE_NAMES.map((n, i) => ({ label: n, value: String(i) })) }),
React.createElement(Select, { value: project.scale,
onChange: v => setProject({ ...project, scale: v }),
options: Object.keys(SCALES).map(s => ({ label: s, value: s })) })
),
React.createElement('div', { className: 'h-10 w-px bg-white/10 hidden lg:block' }),
React.createElement('div', { className: 'hidden lg:flex flex-col' },
React.createElement('div', { className: 'text-[10px] uppercase tracking-widest text-muted-foreground' },
'Swing'),
React.createElement('input', { type: 'range', min: 0, max: 100, value: Math.round(project.swing *
100),
onChange: e => setProject({ ...project, swing: Number(e.target.value) / 100 }),
className: 'audio-slider w-24' })
),
React.createElement('div', { className: 'flex items-center gap-2 ml-auto' },
React.createElement(Volume2, { className: 'h-4 w-4 text-muted-foreground' }),
React.createElement('input', { type: 'range', min: 0, max: 100, value: Math.round(project
.masterVolume * 100),
onChange: e => setProject({ ...project, masterVolume: Number(e.target.value) / 100 }),
className: 'audio-slider w-24' })
),
React.createElement('div', { className: 'h-10 w-px bg-white/10 hidden sm:block' }),
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement(Select, { value: '', placeholder: 'Presets', onChange: v => v && loadPattern(v),
options: Object.keys(PATTERNS).map(p => ({ label: p, value: p })) }),
React.createElement('button', { onClick: randomize,
className: 'h-9 px-3 rounded-lg glass hover:bg-white/10 flex items-center gap-1.5 text-sm font-medium' },
React.createElement(Shuffle, { className: 'h-4 w-4' }),
React.createElement('span', { className: 'hidden sm:inline' }, 'Random')
),
React.createElement('button', { onClick: clearAll,
className: 'h-9 px-3 rounded-lg glass hover:bg-white/10 flex items-center gap-1.5 text-sm font-medium' },
React.createElement(Eraser, { className: 'h-4 w-4' }),
React.createElement('span', { className: 'hidden sm:inline' }, 'Clear')
),
React.createElement('button', { onClick: exportProject,
className: 'h-9 px-3 rounded-lg glass hover:bg-white/10 flex items-center gap-1.5 text-sm font-medium' },
React.createElement(Download, { className: 'h-4 w-4' }),
React.createElement('span', { className: 'hidden sm:inline' }, 'Export')
)
)
);
}
// ── Sequencer ──
function Sequencer() {
const { project, currentStep, toggleStep, updateTrack, previewTrack } = useStudio();
const stepTracks = project.tracks.filter(t => t.kind === 'step');
if (stepTracks.length === 0) {
return React.createElement('div', { className: 'glass-strong rounded-2xl p-6 text-center text-muted-foreground' },
React.createElement('p', { className: 'text-sm' }, 'No step tracks. Add one below!')
);
}
const WAVEFORMS = ['sine', 'triangle', 'sawtooth', 'square'];
const WAVE_LABEL = { sine: 'Sine', triangle: 'Tri', sawtooth: 'Saw', square: 'Sqr' };
return React.createElement('div', { className: 'glass-strong rounded-2xl p-3 sm:p-4 overflow-hidden' },
React.createElement('div', { className: 'flex items-stretch gap-2 sm:gap-3 mb-2' },
React.createElement('div', { className: 'w-[136px] sm:w-[200px] shrink-0' }),
React.createElement('div', { className: 'flex-1 grid grid-cols-[repeat(16,minmax(0,1fr))] gap-1 min-w-[420px]' },
Array.from({ length: STEP_COUNT }).map((_, i) =>
React.createElement('div', { key: i,
className: `text-center text-[10px] font-mono rounded transition-colors ${i % 4 === 0 ? 'text-foreground/70' : 'text-muted-foreground/40'} ${currentStep === i ? 'text-white' : ''}` },
i + 1
)
)
)
),
React.createElement('div', { className: 'space-y-1.5' },
stepTracks.map(track =>
React.createElement('div', { key: track.id, className: 'flex items-stretch gap-2 sm:gap-3 group' },
React.createElement('div', { className: 'w-[136px] sm:w-[200px] shrink-0 glass rounded-xl px-2.5 py-2 flex flex-col justify-center gap-1.5' },
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement('button', { onClick: () => previewTrack(track),
className: 'h-6 w-6 shrink-0 rounded-md bg-gradient-to-br flex items-center justify-center shadow-sm',
style: { background: track.accent } },
React.createElement(Waves, { className: 'h-3.5 w-3.5 text-white/90' })
),
React.createElement('span', { className: 'text-sm font-semibold truncate flex-1' },
track.name),
React.createElement('button', { onClick: () => updateTrack(track.id, { muted: !track
.muted, solo: false }),
className: `h-5 w-5 rounded text-[10px] font-bold leading-none transition-colors ${track.muted ? 'bg-[oklch(0.65_0.24_25)] text-white' : 'glass hover:bg-white/10 text-muted-foreground'}` },
'M'
),
React.createElement('button', { onClick: () => updateTrack(track.id, { solo: !track
.solo, muted: false }),
className: `h-5 w-5 rounded text-[10px] font-bold leading-none transition-colors ${track.solo ? 'bg-[oklch(0.75_0.18_60)] text-black' : 'glass hover:bg-white/10 text-muted-foreground'}` },
'S'
)
),
React.createElement('div', { className: 'flex items-center gap-1.5' },
React.createElement(Volume2, { className: 'h-3 w-3 text-muted-foreground shrink-0' }),
React.createElement('input', { type: 'range', min: 0, max: 100,
value: Math.round(track.volume * 100),
onChange: e => updateTrack(track.id, { volume: Number(e.target.value) /
100 }),
className: 'audio-slider flex-1' }),
React.createElement('div', { className: 'hidden sm:flex items-center gap-1' },
React.createElement('button', { onClick: () => {
const idx = (WAVEFORMS.indexOf(track.waveform || 'sawtooth') +
1) % WAVEFORMS.length;
updateTrack(track.id, { waveform: WAVEFORMS[idx] });
},
className: 'h-5 px-1.5 rounded glass hover:bg-white/10 text-[10px] font-mono' },
WAVE_LABEL[track.waveform || 'sawtooth']
)
)
),
React.createElement('div', { className: 'flex items-center gap-1 -mt-0.5' },
React.createElement('div', { className: 'flex items-center rounded glass overflow-hidden' },
React.createElement('button', { onClick: () => updateTrack(track.id, { octave: Math
.max(1, (track.octave || 3) - 1) }),
className: 'h-5 w-5 hover:bg-white/10 text-[11px] leading-none' }, '−'),
React.createElement('span', { className: 'text-[10px] font-mono w-7 text-center text-muted-foreground' },
`C${track.octave || 3}`),
React.createElement('button', { onClick: () => updateTrack(track.id, { octave: Math
.min(6, (track.octave || 3) + 1) }),
className: 'h-5 w-5 hover:bg-white/10 text-[11px] leading-none' }, '+')
),
React.createElement('button', { onClick: () => updateTrack(track.id, { arp: !track
.arp }),
className: `h-5 px-2 rounded text-[10px] font-bold leading-none transition-colors ${track.arp ? 'bg-gradient-to-r from-[oklch(0.7_0.18_200)] to-[oklch(0.55_0.22_300)] text-white' : 'glass hover:bg-white/10 text-muted-foreground'}` },
'ARP'
)
)
),
React.createElement('div', { className: 'flex-1 grid grid-cols-[repeat(16,minmax(0,1fr))] gap-1 min-w-[420px]' },
track.steps.map((on, i) => {
const isBeat = i % 4 === 0;
const isPlayhead = currentStep === i;
return React.createElement('button', { key: i, onClick: () => toggleStep(track
.id, i),
className: `relative rounded-md h-9 sm:h-11 transition-all duration-75 border step-cell ${on ? 'border-transparent step-cell-on' : isBeat ? 'bg-white/[0.06] border-white/10 hover:bg-white/10' : 'bg-white/[0.02] border-white/5 hover:bg-white/[0.08]'} ${isPlayhead ? 'ring-2 ring-white/70' : ''}`,
style: on ? {
background: track.accent,
boxShadow: isPlayhead ?
`0 0 20px ${track.accent}` :
`0 0 12px -2px ${track.accent}`,
} : undefined,
},
isPlayhead && React.createElement('span', { className: 'absolute inset-0 rounded-md bg-white/20' })
);
})
)
)
)
)
);
}
// ── Audio Track Row ──
function AudioTrackRow({ track }) {
const { updateTrack, removeTrack, previewTrack, engine, project, isPlaying } = useStudio();
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [isPlayingBack, setIsPlayingBack] = useState(false);
const timerRef = useRef(null);
const [waveformUrl, setWaveformUrl] = useState(null);
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (waveformUrl) URL.revokeObjectURL(waveformUrl);
};
}, [waveformUrl]);
const startRecording = async () => {
if (!engine) return;
setIsRecording(true);
setRecordingTime(0);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => setRecordingTime(t => t + 1), 1000);
const ok = await engine.startRecording((blob) => {
const url = URL.createObjectURL(blob);
setWaveformUrl(url);
const reader = new FileReader();
reader.onload = async (e) => {
const arrayBuffer = e.target?.result;
if (!engine.ctx) engine.init();
try {
const audioBuffer = await engine.ctx.decodeAudioData(arrayBuffer);
updateTrack(track.id, {
buffer: audioBuffer,
blobUrl: url,
duration: audioBuffer.duration,
});
} catch (err) {
console.error('Failed to decode recording:', err);
}
};
reader.readAsArrayBuffer(blob);
});
if (!ok) {
setIsRecording(false);
if (timerRef.current) clearInterval(timerRef.current);
alert('Could not access microphone. Please allow microphone access.');
}
};
const stopRecording = async () => {
if (!engine) return;
if (timerRef.current) { clearInterval(timerRef.current);
timerRef.current = null; }
setIsRecording(false);
setRecordingTime(0);
await engine.stopRecording();
};
const toggleRecord = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
const playAudio = () => {
if (!engine || !track.buffer) return;
setIsPlayingBack(true);
engine.playAudioBuffer(track.id, track.buffer, track.volume, track.pan || 0);
const dur = track.buffer.duration || 1;
setTimeout(() => {
setIsPlayingBack(false);
engine.stopAudioBuffer(track.id);
}, dur * 1000 + 300);
};
const stopAudio = () => {
if (!engine) return;
engine.stopAudioBuffer(track.id);
setIsPlayingBack(false);
};
const formatTime = (s) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
};
const hasAudio = !!track.buffer;
return React.createElement('div', { className: 'flex items-stretch gap-2 sm:gap-3 group track-row' },
React.createElement('div', { className: 'w-[136px] sm:w-[200px] shrink-0 glass rounded-xl px-2.5 py-2 flex flex-col justify-center gap-1.5' },
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement('button', { onClick: () => previewTrack(track),
className: 'h-6 w-6 shrink-0 rounded-md bg-gradient-to-br flex items-center justify-center shadow-sm',
style: { background: track.accent } },
React.createElement(Waves, { className: 'h-3.5 w-3.5 text-white/90' })
),
React.createElement('span', { className: 'text-sm font-semibold truncate flex-1' }, track.name),
React.createElement('button', { onClick: () => updateTrack(track.id, { muted: !track.muted,
solo: false }),
className: `h-5 w-5 rounded text-[10px] font-bold leading-none transition-colors ${track.muted ? 'bg-[oklch(0.65_0.24_25)] text-white' : 'glass hover:bg-white/10 text-muted-foreground'}` },
'M'
),
React.createElement('button', { onClick: () => updateTrack(track.id, { solo: !track.solo,
muted: false }),
className: `h-5 w-5 rounded text-[10px] font-bold leading-none transition-colors ${track.solo ? 'bg-[oklch(0.75_0.18_60)] text-black' : 'glass hover:bg-white/10 text-muted-foreground'}` },
'S'
),
React.createElement('button', { onClick: () => removeTrack(track.id),
className: 'h-5 w-5 rounded text-[10px] font-bold leading-none transition-colors hover:bg-white/10 text-muted-foreground' },
React.createElement(Trash2, { className: 'h-3.5 w-3.5' })
)
),
React.createElement('div', { className: 'flex items-center gap-1.5' },
React.createElement(Volume2, { className: 'h-3 w-3 text-muted-foreground shrink-0' }),
React.createElement('input', { type: 'range', min: 0, max: 100,
value: Math.round(track.volume * 100),
onChange: e => updateTrack(track.id, { volume: Number(e.target.value) / 100 }),
className: 'audio-slider flex-1' })
),
React.createElement('div', { className: 'flex items-center gap-1.5 text-[10px] text-muted-foreground' },
React.createElement('span', null, 'Pan'),
React.createElement('input', { type: 'range', min: -100, max: 100,
value: Math.round((track.pan || 0) * 100),
onChange: e => updateTrack(track.id, { pan: Number(e.target.value) / 100 }),
className: 'audio-slider flex-1' })
)
),
React.createElement('div', { className: 'flex-1 min-w-[420px] glass rounded-xl px-3 py-2 flex items-center gap-3' },
hasAudio ?
React.createElement(React.Fragment, null,
React.createElement('button', { onClick: isPlayingBack ? stopAudio : playAudio,
className: 'h-8 w-8 shrink-0 rounded-md flex items-center justify-center bg-gradient-to-br from-[oklch(0.65_0.24_25)] to-[oklch(0.65_0.24_350)] text-white shadow-md' },
isPlayingBack ? React.createElement(Square, { className: 'h-4 w-4 fill-current' }) :
React.createElement(Play, { className: 'h-4 w-4 fill-current ml-0.5' })
),
React.createElement('div', { className: 'flex-1 flex items-center gap-2' },
React.createElement('div', { className: 'flex-1 h-8 rounded bg-white/5 relative overflow-hidden' },
track.buffer && React.createElement('div', { className: 'absolute inset-0 flex items-center gap-[1px] px-1' },
Array.from({ length: 40 }, (_, i) => {
const val = Math.random() * 0.8 + 0.2;
return React.createElement('div', { key: i,
className: 'flex-1 bg-gradient-to-t from-[oklch(0.65_0.24_25)] to-[oklch(0.65_0.24_350)] rounded-sm',
style: { height: `${val * 100}%`, opacity: 0.6 + val *
0.4 } }
);
})
),
isPlayingBack && React.createElement('div', { className: 'absolute inset-0 bg-white/5 animate-pulse' })
),
React.createElement('span', { className: 'text-xs font-mono text-muted-foreground tabular-nums w-16' },
formatTime(track.duration || 0)
)
)
) :
React.createElement('div', { className: 'flex-1 flex items-center justify-center text-xs text-muted-foreground' },
'No audio recorded yet'
),
React.createElement('div', { className: 'flex items-center gap-1.5' },
React.createElement('button', { onClick: toggleRecord,
className: `h-8 px-3 rounded-md flex items-center gap-1.5 text-sm font-medium transition-colors ${isRecording ? 'btn-danger rec-pulse' : 'glass hover:bg-white/10'}` },
isRecording ? React.createElement(React.Fragment, null,
React.createElement(MicOff, { className: 'h-4 w-4' }), ' Stop'
) : React.createElement(React.Fragment, null,
React.createElement(Mic, { className: 'h-4 w-4' }), ' Record'
)
),
isRecording && React.createElement('span', { className: 'text-xs font-mono text-red-400 tabular-nums' },
formatTime(recordingTime)
),
hasAudio && React.createElement('button', { onClick: () => {
if (track.blobUrl) {
const a = document.createElement('a');
a.href = track.blobUrl;
a.download = `${track.name}.webm`;
a.click();
}
},
className: 'h-8 w-8 rounded-md flex items-center justify-center glass hover:bg-white/10' },
React.createElement(Download, { className: 'h-4 w-4' })
)
)
)
);
}
// ── Track Manager ──
function TrackManager() {
const { project, addStepTrack, addAudioTrack } = useStudio();
const [showAddMenu, setShowAddMenu] = useState(false);
const drumOptions = [
{ label: 'Kick', value: 'kick' },
{ label: 'Snare', value: 'snare' },
{ label: 'Clap', value: 'clap' },
{ label: 'Hi-Hat', value: 'hihat' },
{ label: 'Open Hat', value: 'openhat' },
{ label: 'Tom', value: 'tom' },
{ label: 'Rim', value: 'rim' },
{ label: 'Cowbell', value: 'cowbell' },
];
const [newTrackName, setNewTrackName] = useState('');
const [newTrackType, setNewTrackType] = useState('step');
const [selectedDrum, setSelectedDrum] = useState('kick');
const handleAdd = () => {
const name = newTrackName.trim() || (newTrackType === 'step' ? 'Synth' : 'Audio');
if (newTrackType === 'step') {
addStepTrack(name, selectedDrum);
} else {
addAudioTrack(name);
}
setNewTrackName('');
setShowAddMenu(false);
};
const audioTracks = project.tracks.filter(t => t.kind === 'audio');
return React.createElement('div', { className: 'space-y-2' },
React.createElement('div', { className: 'flex items-center gap-3' },
React.createElement('button', { onClick: () => setShowAddMenu(!showAddMenu),
className: 'btn-icon btn-primary' },
React.createElement(Plus, { className: 'h-4 w-4' }), ' Add Track'
),
React.createElement('span', { className: 'text-xs text-muted-foreground' },
`${project.tracks.length} tracks · ${audioTracks.length} audio`
)
),
showAddMenu && React.createElement('div', { className: 'glass-strong rounded-2xl p-4 flex flex-wrap items-end gap-3' },
React.createElement('div', null,
React.createElement('label', { className: 'text-xs text-muted-foreground block mb-1' },
'Track Name'),
React.createElement('input', { type: 'text', value: newTrackName,
onChange: e => setNewTrackName(e.target.value),
placeholder: 'Enter name...',
className: 'bg-white/5 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-[oklch(0.65_0.24_25/50%)] w-40' })
),
React.createElement('div', null,
React.createElement('label', { className: 'text-xs text-muted-foreground block mb-1' },
'Type'),
React.createElement('select', { value: newTrackType,
onChange: e => setNewTrackType(e.target.value),
className: 'bg-white/5 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-[oklch(0.65_0.24_25/50%)]' },
React.createElement('option', { value: 'step' }, 'Step (Synth/Drum)'),
React.createElement('option', { value: 'audio' }, 'Audio (Record)')
)
),
newTrackType === 'step' && React.createElement('div', null,
React.createElement('label', { className: 'text-xs text-muted-foreground block mb-1' },
'Drum Voice'),
React.createElement('select', { value: selectedDrum,
onChange: e => setSelectedDrum(e.target.value),
className: 'bg-white/5 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-[oklch(0.65_0.24_25/50%)]' },
React.createElement('option', { value: '' }, '— Synth (no drum) —'),
drumOptions.map(d => React.createElement('option', { key: d.value, value: d.value }, d
.label))
)
),
React.createElement('button', { onClick: handleAdd,
className: 'btn-icon btn-success' },
React.createElement(Plus, { className: 'h-4 w-4' }), ' Add'
),
React.createElement('button', { onClick: () => setShowAddMenu(false),
className: 'btn-icon' }, 'Cancel')
),
audioTracks.length > 0 && React.createElement('div', { className: 'space-y-1.5 mt-3' },
React.createElement('div', { className: 'text-xs uppercase tracking-wider text-muted-foreground px-1' },
'Audio Tracks'),
audioTracks.map(t => React.createElement(AudioTrackRow, { key: t.id, track: t }))
)
);
}
// ── Project Name Input ──
function ProjectNameInput() {
const { project, setProject } = useStudio();
return React.createElement('input', { value: project.name,
onChange: e => setProject({ ...project, name: e.target.value }),
className: 'bg-transparent outline-none text-sm font-medium text-foreground/90 focus:text-foreground rounded px-2 py-1 hover:bg-white/5 focus:bg-white/5 min-w-0 w-40 sm:w-56 truncate',
'aria-label': 'Project name' });
}
// ── Import Button ──
function ImportButton() {
const { importProject } = useStudio();
const inputRef = useRef(null);
const handleClick = () => inputRef.current?.click();
const handleFile = async (e) => {
const file = e.target.files?.[0];
if (file) {
await importProject(file);
}
e.target.value = '';
};
return React.createElement(React.Fragment, null,
React.createElement('input', { type: 'file', ref: inputRef, accept: '.json', className: 'hidden',
onChange: handleFile }),
React.createElement('button', { onClick: handleClick,
className: 'h-9 px-3 rounded-lg glass hover:bg-white/10 flex items-center gap-1.5 text-sm font-medium' },
React.createElement(Upload, { className: 'h-4 w-4' }),
React.createElement('span', { className: 'hidden sm:inline' }, 'Import')
)
);
}
// ── Main App ──
function StudioApp() {
const { isPlaying, engine } = useStudio();
return React.createElement('div', { className: 'relative min-h-screen flex flex-col bg-background overflow-x-hidden' },
React.createElement('div', { className: 'fixed inset-0 -z-10' },
React.createElement('div', { className: 'absolute inset-0 bg-grid opacity-20' }),
React.createElement('div', { className: 'absolute top-0 left-1/4 h-[480px] w-[480px] rounded-full bg-[oklch(0.65_0.24_25/18%)] blur-[140px]' }),
React.createElement('div', { className: 'absolute bottom-0 right-1/4 h-[480px] w-[480px] rounded-full bg-[oklch(0.55_0.22_300/16%)] blur-[140px]' })
),
React.createElement('header', { className: 'sticky top-0 z-40 glass-strong' },
React.createElement('div', { className: 'container mx-auto max-w-6xl px-4 py-3 flex items-center gap-3' },
React.createElement('a', { href: '/', className: 'flex items-center gap-2 group shrink-0' },
React.createElement(ArrowLeft, { className: 'h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors' }),
React.createElement('div', { className: 'relative h-8 w-8 rounded-lg bg-gradient-to-br from-[oklch(0.65_0.24_25)] via-[oklch(0.65_0.24_350)] to-[oklch(0.55_0.22_300)] flex items-center justify-center shadow-lg shadow-[oklch(0.65_0.24_25/40%)]' },
React.createElement('svg', { viewBox: '0 0 24 24', className: 'h-4 w-4 text-white',
fill: 'currentColor' },
React.createElement('path', { d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z' })
)
)
),
React.createElement('div', { className: 'flex items-baseline gap-2 min-w-0' },
React.createElement('span', { className: 'text-sm font-bold tracking-tight hidden sm:inline' },
'Studio'),
React.createElement(ProjectNameInput, null)
),
React.createElement('div', { className: 'ml-auto flex items-center gap-2' },
React.createElement(ImportButton, null)
)
)
),
React.createElement('main', { className: 'flex-1 container mx-auto max-w-6xl px-4 py-4 sm:py-6 space-y-4' },
React.createElement(Transport, null),
React.createElement(Sequencer, null),
React.createElement(TrackManager, null),
React.createElement('div', { className: 'glass-strong rounded-2xl p-3 sm:p-4' },
React.createElement('div', { className: 'flex items-center justify-between mb-3 px-1' },
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement(Keyboard, { className: 'h-4 w-4 text-muted-foreground' }),
React.createElement('span', { className: 'text-sm font-semibold' },
'Instrument'),
React.createElement('span', { className: 'text-xs text-muted-foreground hidden sm:inline' },
'play with your mouse or the ',
React.createElement('kbd', { className: 'font-mono text-foreground/80' },
'A–L'), ' keys'
)
),
React.createElement('span', { className: 'text-xs text-muted-foreground font-mono hidden sm:inline' },
'press ', React.createElement('kbd', { className: 'text-foreground/80' }, 'space'),
' to ', isPlaying ? 'stop' : 'play'
)
),
React.createElement(PianoKeyboard, { engine: engine, startMidi: 60, octaves: 2,
waveform: 'square' })
),
React.createElement('p', { className: 'text-center text-xs text-muted-foreground pb-6 max-w-2xl mx-auto' },
'A fully in-browser studio powered by the Web Audio API — record audio, build beats with the step sequencer, and play live with the keyboard. Click cells to build a pattern, use ',
React.createElement('span', { className: 'text-foreground/70 font-medium' }, 'ARP'),
' to turn rhythms into melodies. Add audio tracks to record vocals or instruments right in your browser.'
)
)
);
}
// ── Wrap with Provider ──
function App() {
return React.createElement(StudioProvider, null, React.createElement(StudioApp, null));
}
// ── Mount ──
const root = document.getElementById('root');
if (root) {
createRoot(root).render(React.createElement(App));
}
</script>
</body>
</html>
This browser-based studio combines step sequencing, audio recording, and live performance tools.
Multi‑Track Recording: Add audio tracks to record vocals or instruments directly from your microphone. Each audio track includes volume, pan, mute, solo, and a visual waveform display.
Step Sequencer: Build beats with 16‑step patterns for drum and synth tracks. Toggle cells, adjust volume, apply ARP (arpeggiation) for melodies, and change oscillator waveforms (sine, square, sawtooth, triangle).
Transport & Controls: Play/stop with the spacebar, adjust tempo (40‑220 BPM), swing, master volume, and key/scale. Load preset patterns (Boom Bap, House, Trap, etc.), randomize, clear, or export/import your project as JSON.
Live Keyboard: Play the built‑in piano keyboard with your mouse or computer keys (A–L). The keyboard triggers synth voices in real time, perfect for jamming or recording ideas.
Track Management: Add new step tracks (with optional drum voices) or audio tracks. Each track has independent volume, mute, solo, and a preview button to hear its sound.
The audio engine synthesizes drums and synth voices in real time using the Web Audio API, with a look‑ahead scheduler for precise timing and swing feel.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment