Created
June 23, 2026 09:40
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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