Spaces:
Running
Running
| // Tone.js setup | |
| let synth; | |
| let filter; | |
| let reverb; | |
| let distortion; | |
| let delay; | |
| let analyser; | |
| let activeNotes = new Map(); | |
| let filterEnvelope; | |
| let isPlaying = false; | |
| // Initialize audio context on first interaction | |
| let audioContextStarted = false; | |
| function initAudioContext() { | |
| if (!audioContextStarted) { | |
| Tone.start(); | |
| audioContextStarted = true; | |
| console.log('Audio context started'); | |
| return true; | |
| } | |
| return false; | |
| } | |
| // Setup Tone.js components | |
| function setupSynth() { | |
| if (!audioContextStarted) { | |
| console.warn('Audio context not started yet'); | |
| return false; | |
| } | |
| // Clean up existing components if they exist | |
| if (synth) { | |
| synth.dispose(); | |
| } | |
| if (filter) { | |
| filter.dispose(); | |
| } | |
| if (reverb) { | |
| reverb.dispose(); | |
| } | |
| if (distortion) { | |
| distortion.dispose(); | |
| } | |
| if (delay) { | |
| delay.dispose(); | |
| } | |
| if (filterEnvelope) { | |
| filterEnvelope.dispose(); | |
| } | |
| // Create effects | |
| reverb = new Tone.Reverb({ | |
| decay: 2, | |
| wet: 0.3 | |
| }).toDestination(); | |
| distortion = new Tone.Distortion({ | |
| distortion: 0.2, | |
| wet: 0.2 | |
| }).connect(reverb); | |
| delay = new Tone.FeedbackDelay({ | |
| delayTime: 0.25, | |
| feedback: 0.5, | |
| wet: 0.25 | |
| }).connect(distortion); | |
| // Create filter | |
| filter = new Tone.Filter({ | |
| type: "lowpass", | |
| frequency: 1000, | |
| Q: 1 | |
| }).connect(delay); | |
| // Create filter envelope | |
| filterEnvelope = new Tone.FrequencyEnvelope({ | |
| attack: 0.1, | |
| decay: 0.3, | |
| sustain: 0.5, | |
| release: 1.0, | |
| baseFrequency: 200, | |
| octaves: 4 | |
| }); | |
| // Create main synth | |
| synth = new Tone.PolySynth(Tone.Synth, { | |
| oscillator: { | |
| type: "sawtooth" | |
| }, | |
| envelope: { | |
| attack: 0.1, | |
| decay: 0.3, | |
| sustain: 0.5, | |
| release: 1.0 | |
| } | |
| }).connect(filter); | |
| // Modulate filter frequency with envelope | |
| filterEnvelope.connect(filter.frequency); | |
| // Create analyzer for visualizer | |
| analyser = new Tone.Analyser("fft", 64); | |
| synth.connect(analyser); | |
| console.log('Synth initialized'); | |
| return true; | |
| } | |
| // MIDI handling | |
| let midiAccess = null; | |
| let midiInputs = []; | |
| function onMIDISuccess(midi) { | |
| midiAccess = midi; | |
| updateMIDIStatus('CONNECTED'); | |
| document.getElementById('midi-connect-btn').textContent = 'DEVICE CONNECTED'; | |
| document.getElementById('midi-connect-btn').disabled = true; | |
| // Get inputs | |
| const inputs = midi.inputs.values(); | |
| for (let input = inputs.next(); input && !input.done; input = inputs.next()) { | |
| setupMIDIInput(input.value); | |
| } | |
| // Listen for new devices | |
| midi.addEventListener('statechange', onMIDIStateChange); | |
| } | |
| function onMIDIFailure(msg) { | |
| console.error('Failed to get MIDI access - ' + msg); | |
| updateMIDIStatus('CONNECTION FAILED: ' + msg, 'error'); | |
| } | |
| function onMIDIStateChange(event) { | |
| const port = event.port; | |
| if (port.state === "connected") { | |
| if (port.type === "input") { | |
| setupMIDIInput(port); | |
| } | |
| } else if (port.state === "disconnected") { | |
| if (port.type === "input") { | |
| removeMIDIInput(port); | |
| } | |
| } | |
| } | |
| function setupMIDIInput(input) { | |
| midiInputs.push(input); | |
| input.addEventListener('midimessage', onMIDIMessage); | |
| updateMIDIStatus(`CONNECTED TO: ${input.name}`, 'success'); | |
| } | |
| function removeMIDIInput(input) { | |
| midiInputs = midiInputs.filter(i => i.id !== input.id); | |
| updateMIDIStatus(`DISCONNECTED: ${input.name}`, 'warning'); | |
| } | |
| function onMIDIMessage(message) { | |
| const command = message.data[0]; | |
| const note = message.data[1]; | |
| const velocity = message.data[2]; | |
| switch (command) { | |
| case 144: // Note on | |
| if (velocity > 0) { | |
| noteOn(note, velocity); | |
| } else { | |
| noteOff(note); | |
| } | |
| break; | |
| case 128: // Note off | |
| noteOff(note); | |
| break; | |
| } | |
| updateActiveNotesDisplay(); | |
| } | |
| function noteOn(note, velocity) { | |
| if (!synth) { | |
| // Initialize audio context and synth on first note | |
| initAudioContext(); | |
| setupSynth(); | |
| } | |
| const noteName = Tone.Frequency(note, "midi").toNote(); | |
| const velocityNorm = velocity / 127; | |
| synth.triggerAttack(noteName, Tone.now(), velocityNorm); | |
| activeNotes.set(note, { name: noteName, velocity: velocity }); | |
| console.log(`Note On: ${noteName}, Velocity: ${velocity}`); | |
| } | |
| function noteOff(note) { | |
| if (!synth) return; | |
| const noteName = Tone.Frequency(note, "midi").toNote(); | |
| synth.triggerRelease(noteName, Tone.now()); | |
| activeNotes.delete(note); | |
| console.log(`Note Off: ${noteName}`); | |
| } | |
| function updateActiveNotesDisplay() { | |
| const container = document.getElementById('active-notes'); | |
| if (activeNotes.size === 0) { | |
| container.innerHTML = '<div class="text-cyan-700">NO ACTIVE NOTES</div>'; | |
| return; | |
| } | |
| let html = ''; | |
| activeNotes.forEach((note, key) => { | |
| html += `<div class="py-1 px-2 rounded mb-1 bg-gray-900 border border-cyan-900 flex justify-between"> | |
| <span>${note.name}</span> | |
| <span class="text-cyan-600">VEL: ${note.velocity}</span> | |
| </div>`; | |
| }); | |
| container.innerHTML = html; | |
| } | |
| function updateMIDIStatus(message, type = 'info') { | |
| const statusEl = document.getElementById('midi-status'); | |
| statusEl.textContent = message; | |
| statusEl.className = ''; | |
| switch(type) { | |
| case 'success': | |
| statusEl.classList.add('text-green-400'); | |
| break; | |
| case 'warning': | |
| statusEl.classList.add('text-yellow-400'); | |
| break; | |
| case 'error': | |
| statusEl.classList.add('text-red-400'); | |
| break; | |
| default: | |
| statusEl.classList.add('text-cyan-400'); | |
| } | |
| } | |
| // UI Control Updates | |
| function setupUIControls() { | |
| // Oscillator Type | |
| document.getElementById('oscillator-type').addEventListener('change', (e) => { | |
| if (synth) { | |
| synth.set({ oscillator: { type: e.target.value } }); | |
| } | |
| }); | |
| // Oscillator Detune | |
| document.getElementById('oscillator-detune').addEventListener('input', (e) => { | |
| const value = parseInt(e.target.value); | |
| document.getElementById('oscillator-detune-value').textContent = `${value} CENTS`; | |
| if (synth) { | |
| synth.set({ oscillator: { detune: value } }); | |
| } | |
| }); | |
| // Filter Frequency | |
| document.getElementById('filter-frequency').addEventListener('input', (e) => { | |
| const value = parseInt(e.target.value); | |
| document.getElementById('filter-frequency-value').textContent = `${value} HZ`; | |
| if (filter) { | |
| filter.frequency.value = value; | |
| } | |
| }); | |
| // Filter Resonance (Q) | |
| document.getElementById('filter-q').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('filter-q-value').textContent = value.toFixed(1); | |
| if (filter) { | |
| filter.Q.value = value; | |
| } | |
| }); | |
| // Filter Type | |
| document.getElementById('filter-type').addEventListener('change', (e) => { | |
| if (filter) { | |
| filter.type = e.target.value; | |
| } | |
| }); | |
| // Envelope Attack | |
| document.getElementById('envelope-attack').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('envelope-attack-value').textContent = `${value.toFixed(2)} S`; | |
| if (synth) { | |
| synth.set({ envelope: { attack: value } }); | |
| } | |
| drawADSR(); // Update ADSR visualization | |
| }); | |
| // Envelope Decay | |
| document.getElementById('envelope-decay').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('envelope-decay-value').textContent = `${value.toFixed(2)} S`; | |
| if (synth) { | |
| synth.set({ envelope: { decay: value } }); | |
| } | |
| drawADSR(); // Update ADSR visualization | |
| }); | |
| // Envelope Sustain | |
| document.getElementById('envelope-sustain').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('envelope-sustain-value').textContent = `${Math.round(value * 100)}%`; | |
| if (synth) { | |
| synth.set({ envelope: { sustain: value } }); | |
| } | |
| drawADSR(); // Update ADSR visualization | |
| }); | |
| // Envelope Release | |
| document.getElementById('envelope-release').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('envelope-release-value').textContent = `${value.toFixed(1)} S`; | |
| if (synth) { | |
| synth.set({ envelope: { release: value } }); | |
| } | |
| drawADSREnvelope(); // Update ADSR visualization | |
| }); | |
| // Filter Envelope Controls | |
| document.getElementById('filter-attack').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('filter-attack-value').textContent = `${value.toFixed(2)} S`; | |
| if (filterEnvelope) { | |
| filterEnvelope.attack = value; | |
| } | |
| drawFilterEnvelope(); // Update filter ADSR visualization | |
| }); | |
| document.getElementById('filter-decay').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('filter-decay-value').textContent = `${value.toFixed(2)} S`; | |
| if (filterEnvelope) { | |
| filterEnvelope.decay = value; | |
| } | |
| drawFilterEnvelope(); // Update filter ADSR visualization | |
| }); | |
| document.getElementById('filter-sustain').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('filter-sustain-value').textContent = `${Math.round(value * 100)}%`; | |
| if (filterEnvelope) { | |
| filterEnvelope.sustain = value; | |
| } | |
| drawFilterEnvelope(); // Update filter ADSR visualization | |
| }); | |
| document.getElementById('filter-release').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('filter-release-value').textContent = `${value.toFixed(1)} S`; | |
| if (filterEnvelope) { | |
| filterEnvelope.release = value; | |
| } | |
| drawFilterEnvelope(); // Update filter ADSR visualization | |
| }); | |
| // Reverb Wet | |
| document.getElementById('reverb-wet').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('reverb-wet-value').textContent = `${Math.round(value * 100)}%`; | |
| if (reverb) { | |
| reverb.wet.value = value; | |
| } | |
| }); | |
| // Distortion Wet | |
| document.getElementById('distortion-wet').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('distortion-wet-value').textContent = `${Math.round(value * 100)}%`; | |
| if (distortion) { | |
| distortion.wet.value = value; | |
| } | |
| }); | |
| // Delay Wet | |
| document.getElementById('delay-wet').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('delay-wet-value').textContent = `${Math.round(value * 100)}%`; | |
| if (delay) { | |
| delay.wet.value = value; | |
| } | |
| }); | |
| } | |
| // Visualizer | |
| function setupVisualizer() { | |
| const canvas = document.getElementById('visualizer'); | |
| const ctx = canvas.getContext('2d'); | |
| function resizeCanvas() { | |
| canvas.width = canvas.clientWidth; | |
| canvas.height = canvas.clientHeight; | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| resizeCanvas(); | |
| function draw() { | |
| if (!analyser) { | |
| requestAnimationFrame(draw); | |
| return; | |
| } | |
| const values = analyser.getValue(); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw grid | |
| ctx.strokeStyle = 'rgba(6, 182, 212, 0.1)'; | |
| ctx.lineWidth = 1; | |
| // Vertical lines | |
| for (let x = 0; x < canvas.width; x += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, canvas.height); | |
| ctx.stroke(); | |
| } | |
| // Horizontal lines | |
| for (let y = 0; y < canvas.height; y += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(canvas.width, y); | |
| ctx.stroke(); | |
| } | |
| // Draw waveform | |
| ctx.beginPath(); | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = '#06b6d4'; // cyan-500 | |
| const sliceWidth = canvas.width / values.length; | |
| let x = 0; | |
| for (let i = 0; i < values.length; i++) { | |
| const v = (values[i] + 1) / 2; | |
| const y = v * canvas.height; | |
| if (i === 0) { | |
| ctx.moveTo(x, y); | |
| } else { | |
| ctx.lineTo(x, y); | |
| } | |
| x += sliceWidth; | |
| } | |
| ctx.stroke(); | |
| requestAnimationFrame(draw); | |
| } | |
| draw(); | |
| } | |
| // ADSR Visualizer | |
| function setupADSRVisualizer() { | |
| const canvas = document.getElementById('adsr-visualizer'); | |
| const ctx = canvas.getContext('2d'); | |
| function resizeCanvas() { | |
| canvas.width = canvas.clientWidth; | |
| canvas.height = canvas.clientHeight; | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| resizeCanvas(); | |
| // Initial draw | |
| drawADSR(); | |
| function drawADSR() { | |
| if (!canvas.width || !canvas.height) return; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw grid | |
| ctx.strokeStyle = 'rgba(6, 182, 212, 0.1)'; | |
| ctx.lineWidth = 1; | |
| // Vertical lines | |
| for (let x = 0; x < canvas.width; x += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, canvas.height); | |
| ctx.stroke(); | |
| } | |
| // Horizontal lines | |
| for (let y = 0; y < canvas.height; y += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(canvas.width, y); | |
| ctx.stroke(); | |
| } | |
| // Draw ADSR envelope | |
| const attack = parseFloat(document.getElementById('envelope-attack').value); | |
| const decay = parseFloat(document.getElementById('envelope-decay').value); | |
| const sustain = parseFloat(document.getElementById('envelope-sustain').value); | |
| const release = parseFloat(document.getElementById('envelope-release').value); | |
| // Normalize values for visualization | |
| const totalTime = attack + decay + 2 + release; // Add some time for sustain and release visualization | |
| const attackWidth = (attack / totalTime) * canvas.width; | |
| const decayWidth = (decay / totalTime) * canvas.width; | |
| const sustainWidth = (2 / totalTime) * canvas.width; | |
| const releaseWidth = (release / totalTime) * canvas.width; | |
| ctx.beginPath(); | |
| ctx.lineWidth = 3; | |
| ctx.strokeStyle = '#06b6d4'; // cyan-500 | |
| // Start at 0 | |
| ctx.moveTo(0, canvas.height); | |
| // Attack | |
| ctx.lineTo(attackWidth, 0); | |
| // Decay | |
| const sustainHeight = canvas.height - (sustain * canvas.height); | |
| ctx.lineTo(attackWidth + decayWidth, sustainHeight); | |
| // Sustain | |
| ctx.lineTo(attackWidth + decayWidth + sustainWidth, sustainHeight); | |
| // Release | |
| ctx.lineTo(attackWidth + decayWidth + sustainWidth + releaseWidth, canvas.height); | |
| ctx.stroke(); | |
| // Draw labels | |
| ctx.fillStyle = '#06b6d4'; | |
| ctx.font = '10px monospace'; | |
| ctx.fillText('A', 5, canvas.height - 5); | |
| ctx.fillText('D', attackWidth - 10, canvas.height - 5); | |
| ctx.fillText('S', attackWidth + decayWidth - 10, sustainHeight - 5); | |
| ctx.fillText('R', attackWidth + decayWidth + sustainWidth + releaseWidth - 15, canvas.height - 5); | |
| } | |
| // Make drawADSR accessible globally for updates | |
| window.drawADSR = drawADSR; | |
| } | |
| // Initialize everything when page loads | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Setup UI controls | |
| setupUIControls(); | |
| // Setup visualizers | |
| setupVisualizer(); | |
| // setupADSRVisualizer(); // This function doesn't exist, using draw functions directly | |
| // Initial draw of ADSR envelopes | |
| drawADSR(); | |
| drawFilterEnvelope(); | |
| // MIDI connection button | |
| document.getElementById('midi-connect-btn').addEventListener('click', () => { | |
| const wasStarted = initAudioContext(); | |
| if (wasStarted) { | |
| setupSynth(); | |
| } | |
| if (navigator.requestMIDIAccess) { | |
| navigator.requestMIDIAccess() | |
| .then(onMIDISuccess, onMIDIFailure); | |
| } else { | |
| updateMIDIStatus('WEBMIDI NOT SUPPORTED', 'error'); | |
| } | |
| }); | |
| }); | |
| // Add missing ADSR drawing functions | |
| function drawADSR() { | |
| const canvas = document.getElementById('amp-envelope-editor'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| // Set canvas size | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = canvas.clientWidth * dpr; | |
| canvas.height = canvas.clientHeight * dpr; | |
| ctx.scale(dpr, dpr); | |
| // Clear canvas | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw grid | |
| ctx.strokeStyle = 'rgba(6, 182, 212, 0.1)'; | |
| ctx.lineWidth = 1; | |
| const width = canvas.clientWidth; | |
| const height = canvas.clientHeight; | |
| // Vertical lines | |
| for (let x = 0; x < width; x += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, height); | |
| ctx.stroke(); | |
| } | |
| // Horizontal lines | |
| for (let y = 0; y < height; y += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(width, y); | |
| ctx.stroke(); | |
| } | |
| // Draw ADSR envelope | |
| const attack = parseFloat(document.getElementById('envelope-attack').value); | |
| const decay = parseFloat(document.getElementById('envelope-decay').value); | |
| const sustain = parseFloat(document.getElementById('envelope-sustain').value); | |
| const release = parseFloat(document.getElementById('envelope-release').value); | |
| // Normalize values for visualization (max 5 seconds for attack/decay, 10 for release) | |
| const attackX = (attack / 5) * width * 0.2; | |
| const decayX = (decay / 5) * width * 0.2; | |
| const sustainX = width * 0.4; // Fixed sustain time | |
| const releaseX = (release / 10) * width * 0.2; | |
| const sustainY = height - (sustain * height); | |
| ctx.beginPath(); | |
| ctx.lineWidth = 3; | |
| ctx.strokeStyle = '#06b6d4'; // cyan-500 | |
| // Start at 0 | |
| ctx.moveTo(0, height); | |
| // Attack | |
| ctx.lineTo(attackX, 0); | |
| // Decay | |
| ctx.lineTo(attackX + decayX, sustainY); | |
| // Sustain | |
| ctx.lineTo(attackX + decayX + sustainX, sustainY); | |
| // Release | |
| ctx.lineTo(attackX + decayX + sustainX + releaseX, height); | |
| ctx.stroke(); | |
| } | |
| function drawFilterEnvelope() { | |
| const canvas = document.getElementById('filter-envelope-editor'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| // Set canvas size | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = canvas.clientWidth * dpr; | |
| canvas.height = canvas.clientHeight * dpr; | |
| ctx.scale(dpr, dpr); | |
| // Clear canvas | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw grid | |
| ctx.strokeStyle = 'rgba(6, 182, 212, 0.1)'; | |
| ctx.lineWidth = 1; | |
| const width = canvas.clientWidth; | |
| const height = canvas.clientHeight; | |
| // Vertical lines | |
| for (let x = 0; x < width; x += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, height); | |
| ctx.stroke(); | |
| } | |
| // Horizontal lines | |
| for (let y = 0; y < height; y += 20) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(width, y); | |
| ctx.stroke(); | |
| } | |
| // Draw ADSR envelope | |
| const attack = parseFloat(document.getElementById('filter-attack').value); | |
| const decay = parseFloat(document.getElementById('filter-decay').value); | |
| const sustain = parseFloat(document.getElementById('filter-sustain').value); | |
| const release = parseFloat(document.getElementById('filter-release').value); | |
| // Normalize values for visualization (max 5 seconds for attack/decay, 10 for release) | |
| const attackX = (attack / 5) * width * 0.2; | |
| const decayX = (decay / 5) * width * 0.2; | |
| const sustainX = width * 0.4; // Fixed sustain time | |
| const releaseX = (release / 10) * width * 0.2; | |
| const sustainY = height - (sustain * height); | |
| ctx.beginPath(); | |
| ctx.lineWidth = 3; | |
| ctx.strokeStyle = '#06b6d4'; // cyan-500 | |
| // Start at 0 | |
| ctx.moveTo(0, height); | |
| // Attack | |
| ctx.lineTo(attackX, 0); | |
| // Decay | |
| ctx.lineTo(attackX + decayX, sustainY); | |
| // Sustain | |
| ctx.lineTo(attackX + decayX + sustainX, sustainY); | |
| // Release | |
| ctx.lineTo(attackX + decayX + sustainX + releaseX, height); | |
| ctx.stroke(); | |
| } | |
| // Update envelope drawing when controls change | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Amplitude envelope controls | |
| document.getElementById('envelope-attack').addEventListener('input', drawADSR); | |
| document.getElementById('envelope-decay').addEventListener('input', drawADSR); | |
| document.getElementById('envelope-sustain').addEventListener('input', drawADSR); | |
| document.getElementById('envelope-release').addEventListener('input', drawADSR); | |
| // Filter envelope controls | |
| document.getElementById('filter-attack').addEventListener('input', drawFilterEnvelope); | |
| document.getElementById('filter-decay').addEventListener('input', drawFilterEnvelope); | |
| document.getElementById('filter-sustain').addEventListener('input', drawFilterEnvelope); | |
| document.getElementById('filter-release').addEventListener('input', drawFilterEnvelope); | |
| }); | |