Spaces:
Running
Running
| /** | |
| * app.js | |
| * | |
| * Browser-based mini-DAW demo with: | |
| * - Simple 4-track Web Audio drum step sequencer | |
| * - Text-based "piano roll" notes area | |
| * - AI Song Starter powered by transformers.js running in a Web Worker | |
| * | |
| * This is intentionally focused: in a real implementation you would plug | |
| * this UI and audio engine into the much larger architecture described | |
| * in the technical spec (cloud sync, collaboration, Web3, etc.). | |
| */ | |
| const GRID_STEPS = 16; | |
| const TRACKS = [ | |
| { name: "Kick", freq: 60, trackIndex: 0 }, | |
| { name: "Snare", freq: 180, trackIndex: 1 }, | |
| { name: "Hi-hat", freq: 4000, trackIndex: 2 }, | |
| { name: "Bass", freq: 90, trackIndex: 3 }, // simple bass thump | |
| ]; | |
| // Simple in-memory "project" state | |
| const state = { | |
| grid: Array(TRACKS.length) | |
| .fill(null) | |
| .map(() => Array(GRID_STEPS).fill(false)), | |
| bpm: 90, | |
| isPlaying: false, | |
| currentStep: 0, | |
| audio: { | |
| ctx: null, | |
| masterGain: null, | |
| trackGains: [], | |
| metronomeGain: null, | |
| }, | |
| }; | |
| // DOM references | |
| const gridEl = document.getElementById("step-grid"); | |
| const playBtn = document.getElementById("play-btn"); | |
| const stopBtn = document.getElementById("stop-btn"); | |
| const clearGridBtn = document.getElementById("clear-grid-btn"); | |
| const bpmInput = document.getElementById("project-bpm"); | |
| const metronomeToggle = document.getElementById("metronome-toggle"); | |
| const exportJsonBtn = document.getElementById("export-json-btn"); | |
| const downloadLink = document.getElementById("download-link"); | |
| // AI panel refs | |
| const aiGenreInput = document.getElementById("ai-genre"); | |
| const aiMoodInput = document.getElementById("ai-mood"); | |
| const aiTaskSelect = document.getElementById("ai-task"); | |
| const aiOutput = document.getElementById("ai-output"); | |
| const aiError = document.getElementById("ai-error"); | |
| const aiGenerateBtn = document.getElementById("generate-idea-btn"); | |
| const modelStatus = document.getElementById("model-status"); | |
| const modelProgress = document.getElementById("model-progress"); | |
| const progressText = document.getElementById("model-progress-text"); | |
| const progressBarInner = document.getElementById("model-progress-bar-inner"); | |
| const exampleChips = document.querySelectorAll(".chip"); | |
| // Melody notes area (text-based "piano roll") | |
| const melodyNotesArea = document.getElementById("melody-notes"); | |
| // Audio scheduling | |
| let nextNoteTime = 0; | |
| let stepTimerId = null; | |
| /** | |
| * AUDIO ENGINE | |
| */ | |
| function ensureAudioContext() { | |
| if (state.audio.ctx) return; | |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const masterGain = ctx.createGain(); | |
| masterGain.gain.value = 0.9; | |
| masterGain.connect(ctx.destination); | |
| const trackGains = TRACKS.map(() => { | |
| const g = ctx.createGain(); | |
| g.gain.value = 0.8; | |
| g.connect(masterGain); | |
| return g; | |
| }); | |
| const metGain = ctx.createGain(); | |
| metGain.gain.value = 0.0; | |
| metGain.connect(masterGain); | |
| state.audio.ctx = ctx; | |
| state.audio.masterGain = masterGain; | |
| state.audio.trackGains = trackGains; | |
| state.audio.metronomeGain = metGain; | |
| } | |
| function triggerDrum(trackIndex, time) { | |
| const ctx = state.audio.ctx; | |
| const freq = TRACKS[trackIndex].freq; | |
| if (trackIndex === 0) { | |
| // Kick: short decaying sine | |
| const osc = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| osc.type = "sine"; | |
| osc.frequency.setValueAtTime(freq, time); | |
| osc.frequency.exponentialRampToValueAtTime(40, time + 0.1); | |
| gain.gain.setValueAtTime(0.9, time); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + 0.2); | |
| osc.connect(gain); | |
| gain.connect(state.audio.trackGains[trackIndex]); | |
| osc.start(time); | |
| osc.stop(time + 0.25); | |
| } else if (trackIndex === 1) { | |
| // Snare: noise + high tone | |
| const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.2, ctx.sampleRate); | |
| const data = noiseBuf.getChannelData(0); | |
| for (let i = 0; i < data.length; i++) { | |
| data[i] = (Math.random() * 2 - 1) * 0.5; | |
| } | |
| const noise = ctx.createBufferSource(); | |
| noise.buffer = noiseBuf; | |
| const noiseGain = ctx.createGain(); | |
| noiseGain.gain.setValueAtTime(0.7, time); | |
| noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.2); | |
| const bandpass = ctx.createBiquadFilter(); | |
| bandpass.type = "bandpass"; | |
| bandpass.frequency.value = 1800; | |
| bandpass.Q.value = 0.5; | |
| noise.connect(bandpass); | |
| bandpass.connect(noiseGain); | |
| noiseGain.connect(state.audio.trackGains[trackIndex]); | |
| noise.start(time); | |
| noise.stop(time + 0.25); | |
| } else if (trackIndex === 2) { | |
| // Hi-hat: high-pass noise | |
| const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.08, ctx.sampleRate); | |
| const data = noiseBuf.getChannelData(0); | |
| for (let i = 0; i < data.length; i++) { | |
| data[i] = (Math.random() * 2 - 1) * 0.4; | |
| } | |
| const noise = ctx.createBufferSource(); | |
| noise.buffer = noiseBuf; | |
| const hp = ctx.createBiquadFilter(); | |
| hp.type = "highpass"; | |
| hp.frequency.value = 7000; | |
| const g = ctx.createGain(); | |
| g.gain.setValueAtTime(0.5, time); | |
| g.gain.exponentialRampToValueAtTime(0.001, time + 0.08); | |
| noise.connect(hp); | |
| hp.connect(g); | |
| g.connect(state.audio.trackGains[trackIndex]); | |
| noise.start(time); | |
| noise.stop(time + 0.1); | |
| } else if (trackIndex === 3) { | |
| // Bass thump | |
| const osc = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| osc.type = "sawtooth"; | |
| osc.frequency.setValueAtTime(freq, time); | |
| gain.gain.setValueAtTime(0.35, time); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + 0.25); | |
| osc.connect(gain); | |
| gain.connect(state.audio.trackGains[trackIndex]); | |
| osc.start(time); | |
| osc.stop(time + 0.3); | |
| } | |
| } | |
| function triggerMetronome(time, isBarStart) { | |
| if (!metronomeToggle.checked) return; | |
| const ctx = state.audio.ctx; | |
| const osc = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| osc.type = "square"; | |
| osc.frequency.value = isBarStart ? 2200 : 1400; | |
| gain.gain.setValueAtTime(isBarStart ? 0.45 : 0.28, time); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); | |
| osc.connect(gain); | |
| gain.connect(state.audio.metronomeGain); | |
| osc.start(time); | |
| osc.stop(time + 0.08); | |
| } | |
| function scheduleNextStep() { | |
| if (!state.isPlaying || !state.audio.ctx) return; | |
| const secondsPerBeat = 60 / state.bpm; | |
| const stepDuration = secondsPerBeat / 4; // 16th note | |
| const ctx = state.audio.ctx; | |
| while (nextNoteTime < ctx.currentTime + 0.1) { | |
| const stepIndex = state.currentStep; | |
| const isBarStart = stepIndex % 4 === 0; | |
| // Schedule metronome | |
| triggerMetronome(nextNoteTime, isBarStart); | |
| // Schedule tracks if active | |
| for (let t = 0; t < TRACKS.length; t++) { | |
| if (state.grid[t][stepIndex]) { | |
| triggerDrum(t, nextNoteTime); | |
| } | |
| } | |
| // Visual highlight | |
| highlightStep(stepIndex); | |
| // Advance | |
| state.currentStep = (state.currentStep + 1) % GRID_STEPS; | |
| nextNoteTime += stepDuration; | |
| } | |
| stepTimerId = requestAnimationFrame(scheduleNextStep); | |
| } | |
| function startPlayback() { | |
| ensureAudioContext(); | |
| if (state.isPlaying) return; | |
| const ctx = state.audio.ctx; | |
| if (ctx.state === "suspended") ctx.resume(); | |
| state.isPlaying = true; | |
| nextNoteTime = ctx.currentTime + 0.05; | |
| state.currentStep = 0; | |
| scheduleNextStep(); | |
| } | |
| function stopPlayback() { | |
| state.isPlaying = false; | |
| if (stepTimerId) cancelAnimationFrame(stepTimerId); | |
| stepTimerId = null; | |
| clearStepHighlight(); | |
| } | |
| /** | |
| * GRID UI | |
| */ | |
| function buildGrid() { | |
| gridEl.innerHTML = ""; | |
| gridEl.style.setProperty("--grid-cols", GRID_STEPS); | |
| for (let trackIndex = 0; trackIndex < TRACKS.length; trackIndex++) { | |
| for (let step = 0; step < GRID_STEPS; step++) { | |
| const cell = document.createElement("div"); | |
| cell.className = "step-cell"; | |
| cell.dataset.track = String(trackIndex); | |
| cell.dataset.step = String(step); | |
| cell.addEventListener("click", onGridCellClick); | |
| gridEl.appendChild(cell); | |
| } | |
| } | |
| } | |
| function onGridCellClick(e) { | |
| const cell = e.currentTarget; | |
| const trackIndex = Number(cell.dataset.track); | |
| const step = Number(cell.dataset.step); | |
| state.grid[trackIndex][step] = !state.grid[trackIndex][step]; | |
| cell.classList.toggle("active", state.grid[trackIndex][step]); | |
| // One-shot preview when clicking while stopped | |
| if (!state.isPlaying) { | |
| ensureAudioContext(); | |
| triggerDrum(trackIndex, state.audio.ctx.currentTime + 0.01); | |
| } | |
| } | |
| function highlightStep(stepIndex) { | |
| const cells = gridEl.querySelectorAll(".step-cell"); | |
| cells.forEach((cell) => { | |
| const s = Number(cell.dataset.step); | |
| if (s === stepIndex) { | |
| cell.style.boxShadow = "0 0 0 1px rgba(159,210,123,0.9), 0 0 12px rgba(159,210,123,0.7)"; | |
| } else { | |
| cell.style.boxShadow = "none"; | |
| } | |
| }); | |
| } | |
| function clearStepHighlight() { | |
| const cells = gridEl.querySelectorAll(".step-cell"); | |
| cells.forEach((cell) => (cell.style.boxShadow = "none")); | |
| } | |
| /** | |
| * TRACK CONTROLS (mute/solo/gain) | |
| */ | |
| function initTrackControls() { | |
| const muteButtons = document.querySelectorAll(".mute-btn"); | |
| const soloButtons = document.querySelectorAll(".solo-btn"); | |
| const faders = document.querySelectorAll('.track-faders input[type="range"]'); | |
| muteButtons.forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| const trackIndex = Number(btn.dataset.track); | |
| btn.classList.toggle("active"); | |
| updateTrackGains(); | |
| }); | |
| }); | |
| soloButtons.forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| const trackIndex = Number(btn.dataset.track); | |
| btn.classList.toggle("active"); | |
| updateTrackGains(); | |
| }); | |
| }); | |
| faders.forEach((fader) => { | |
| fader.addEventListener("input", () => { | |
| const trackIndex = Number(fader.dataset.track); | |
| const value = Number(fader.value); | |
| ensureAudioContext(); | |
| state.audio.trackGains[trackIndex].gain.value = value; | |
| }); | |
| }); | |
| } | |
| function updateTrackGains() { | |
| ensureAudioContext(); | |
| const mutes = document.querySelectorAll(".mute-btn"); | |
| const solos = document.querySelectorAll(".solo-btn"); | |
| const faders = document.querySelectorAll('.track-faders input[type="range"]'); | |
| const soloed = new Set( | |
| Array.from(solos) | |
| .filter((b) => b.classList.contains("active")) | |
| .map((b) => Number(b.dataset.track)) | |
| ); | |
| for (let i = 0; i < TRACKS.length; i++) { | |
| const isMuted = Array.from(mutes).some( | |
| (b) => Number(b.dataset.track) === i && b.classList.contains("active") | |
| ); | |
| const hasSolo = soloed.size > 0; | |
| const isSoloed = soloed.has(i); | |
| const baseGain = | |
| Number( | |
| Array.from(faders).find((f) => Number(f.dataset.track) === i)?.value ?? 0.8 | |
| ) || 0.8; | |
| let gain = baseGain; | |
| if (hasSolo && !isSoloed) { | |
| gain = 0; | |
| } else if (isMuted) { | |
| gain = 0; | |
| } | |
| state.audio.trackGains[i].gain.value = gain; | |
| } | |
| } | |
| /** | |
| * EXPORT (simple JSON snapshot, illustrating serialization) | |
| */ | |
| function exportProjectJson() { | |
| const project = { | |
| title: document.getElementById("project-title").value || "Untitled Web DAW Sketch", | |
| bpm: state.bpm, | |
| bars: 4, | |
| gridSteps: GRID_STEPS, | |
| tracks: TRACKS.map((t, i) => ({ | |
| name: t.name, | |
| index: i, | |
| pattern: state.grid[i], | |
| })), | |
| melodyNotes: melodyNotesArea.value, | |
| aiNotes: aiOutput.value, | |
| createdAt: new Date().toISOString(), | |
| }; | |
| const blob = new Blob([JSON.stringify(project, null, 2)], { | |
| type: "application/json", | |
| }); | |
| const url = URL.createObjectURL(blob); | |
| const filename = `${(project.title || "project").replace(/\s+/g, "_")}.json`; | |
| downloadLink.href = url; | |
| downloadLink.download = filename; | |
| downloadLink.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 10000); | |
| } | |
| /** | |
| * AI WORKER / TRANSFORMERS.JS | |
| */ | |
| // Web worker instance | |
| let aiWorker = null; | |
| let modelReady = false; | |
| function initWorker() { | |
| aiWorker = new Worker("worker.js", { type: "module" }); | |
| aiWorker.onmessage = (event) => { | |
| const { type, payload } = event.data || {}; | |
| switch (type) { | |
| case "status": | |
| handleWorkerStatus(payload); | |
| break; | |
| case "progress": | |
| handleWorkerProgress(payload); | |
| break; | |
| case "ready": | |
| modelReady = true; | |
| modelStatus.textContent = "Model loaded – you can now generate ideas."; | |
| modelStatus.className = "status status-ok"; | |
| aiGenerateBtn.disabled = false; | |
| aiGenerateBtn.textContent = "Generate AI idea"; | |
| modelProgress.classList.add("hidden"); | |
| break; | |
| case "result": | |
| handleWorkerResult(payload); | |
| break; | |
| case "error": | |
| handleWorkerError(payload); | |
| break; | |
| default: | |
| break; | |
| } | |
| }; | |
| aiWorker.onerror = (e) => { | |
| console.error("Worker error:", e); | |
| aiError.textContent = | |
| "Worker error while loading transformers.js. Check console for details."; | |
| aiError.classList.remove("hidden"); | |
| modelStatus.textContent = "Error initializing model."; | |
| modelStatus.className = "status status-error"; | |
| aiGenerateBtn.disabled = true; | |
| }; | |
| } | |
| function handleWorkerStatus(message) { | |
| modelStatus.textContent = message; | |
| } | |
| function handleWorkerProgress({ loaded, total }) { | |
| if (!total || total <= 0) return; | |
| const pct = Math.round((loaded / total) * 100); | |
| progressText.textContent = `${pct}%`; | |
| progressBarInner.style.width = `${pct}%`; | |
| modelProgress.classList.remove("hidden"); | |
| } | |
| function handleWorkerResult({ text }) { | |
| aiGenerateBtn.disabled = false; | |
| aiGenerateBtn.textContent = "Generate AI idea"; | |
| aiError.classList.add("hidden"); | |
| const trimmed = text.trim(); | |
| aiOutput.value = trimmed; | |
| // Simple heuristic: if chord or drum idea, also drop into melody notes area as a sketch | |
| if (aiTaskSelect.value === "chords" || aiTaskSelect.value === "melody") { | |
| melodyNotesArea.value = trimmed; | |
| } | |
| } | |
| function handleWorkerError({ error }) { | |
| console.error("AI error:", error); | |
| aiGenerateBtn.disabled = false; | |
| aiGenerateBtn.textContent = "Generate AI idea"; | |
| aiError.textContent = error || "Unknown error from AI worker."; | |
| aiError.classList.remove("hidden"); | |
| } | |
| function triggerAIGeneration() { | |
| if (!aiWorker || !modelReady) return; | |
| aiError.classList.add("hidden"); | |
| const genre = (aiGenreInput.value || "lofi hip hop").trim(); | |
| const mood = (aiMoodInput.value || "chill").trim(); | |
| const task = aiTaskSelect.value; | |
| const bpm = Number(bpmInput.value) || 90; | |
| const key = (document.getElementById("project-key").value || "C minor").trim(); | |
| let prompt; | |
| if (task === "chords") { | |
| prompt = `You are an expert music theory assistant for beatmakers. Suggest a 4-bar chord progression in ${key} for a ${genre} track with a ${mood} vibe at ${bpm} BPM. | |
| Return ONLY a compact, bar-by-bar text description, one bar per line. Example format: | |
| Bar 1: Cmin7 - Gmin7 | |
| Bar 2: Ebmaj7 - Fmin7 | |
| Bar 3: Abmaj7 - Gmin7 | |
| Bar 4: turnaround...`; | |
| } else if (task === "drums") { | |
| prompt = `You are a drum programmer helping a producer. Suggest a 1-bar, 16-step drum pattern for a ${genre} beat (${mood}, ${bpm} BPM). | |
| Use a compact ASCII grid with K (kick), S (snare), H (hi-hat), . for rest, grouped by 4 steps per beat. Example: | |
| Kick : K..K .... K..K .... | |
| Snare: .... S... .... S... | |
| Hat : H.H. H.H. H.H. H.H.`; | |
| } else if (task === "melody") { | |
| prompt = `You are a melody writer. Based on ${genre} with a ${mood} vibe in ${key} at ${bpm} BPM, | |
| suggest a simple 2-bar hook melody. Return a clear, text-only description like: | |
| Bar 1: notes (timing), e.g. C4 (1&), D4 (1e), ... | |
| Bar 2: ...`; | |
| } else if (task === "arrangement") { | |
| prompt = `You are a modern music producer. For a ${genre} track with a ${mood} vibe in ${key} at ${bpm} BPM, | |
| suggest a simple A/B arrangement for 16 bars. Example: | |
| Bars 1-4: Intro (filtered drums, no bass) | |
| Bars 5-8: A-section (full drums, bass, chords) | |
| Bars 9-12: B-section (add lead, remove hi-hats) | |
| Bars 13-16: Drop / outro.`; | |
| } else if (task === "mix") { | |
| prompt = `You are an AI mix engineer. The user has a beat in ${genre} (${mood}, ${bpm} BPM, key ${key}). | |
| Give concise bullet-point mixing tips focusing on kick, bass, drums bus, and main melody. | |
| Keep it short, text-only.`; | |
| } | |
| aiGenerateBtn.disabled = true; | |
| aiGenerateBtn |