Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Analog Synth Emulator</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/Tone.min.js"></script> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
<style> | |
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&display=swap'); | |
:root { | |
--knob-size: 50px; | |
--panel-color: #111827; | |
--accent-color: #8b5cf6; | |
--highlight-color: #c4b5fd; | |
} | |
body { | |
font-family: 'Major Mono Display', monospace; | |
background-color: #1f2937; | |
color: white; | |
overflow-x: hidden; | |
} | |
.synth-panel { | |
background: linear-gradient(145deg, #111827, #1e293b); | |
border: 1px solid #374151; | |
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); | |
} | |
.knob { | |
width: var(--knob-size); | |
height: var(--knob-size); | |
border-radius: 50%; | |
background: radial-gradient(circle at 30% 30%, #4b5563, #1f2937 75%); | |
border: 2px solid #374151; | |
position: relative; | |
cursor: pointer; | |
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); | |
} | |
.knob::after { | |
content: ''; | |
position: absolute; | |
top: 5px; | |
left: 50%; | |
width: 3px; | |
height: 15px; | |
background-color: var(--highlight-color); | |
transform-origin: bottom center; | |
transform: translateX(-50%) rotate(0deg); | |
border-radius: 3px; | |
} | |
.button { | |
width: var(--knob-size); | |
height: var(--knob-size); | |
border-radius: 50%; | |
background: radial-gradient(circle at 30% 30%, #4b5563, #1f2937 75%); | |
border: 2px solid #374151; | |
cursor: pointer; | |
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
transition: all 0.3s; | |
} | |
.button.active { | |
background: radial-gradient(circle at 30% 30%, var(--accent-color), #1f2937 75%); | |
box-shadow: 0 0 10px var(--highlight-color); | |
} | |
.slider { | |
-webkit-appearance: none; | |
width: 100%; | |
height: 6px; | |
background: linear-gradient(90deg, var(--accent-color), #4b5563); | |
border-radius: 3px; | |
outline: none; | |
} | |
.slider::-webkit-slider-thumb { | |
-webkit-appearance: none; | |
width: 18px; | |
height: 18px; | |
border-radius: 50%; | |
background: var(--highlight-color); | |
cursor: pointer; | |
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); | |
} | |
.key { | |
flex: 1; | |
height: 120px; | |
border: 1px solid #111827; | |
position: relative; | |
display: flex; | |
justify-content: center; | |
align-items: flex-end; | |
padding-bottom: 10px; | |
cursor: pointer; | |
transition: all 0.1s; | |
user-select: none; | |
} | |
.key.white { | |
background: linear-gradient(180deg, #f9fafb, #d1d5db); | |
color: #111827; | |
z-index: 1; | |
box-shadow: inset 0 -5px 10px rgba(0, 0, 0, 0.2); | |
} | |
.key.black { | |
background: linear-gradient(180deg, #111827, #4b5563); | |
width: 60%; | |
height: 80px; | |
margin-left: -15%; | |
margin-right: -15%; | |
z-index: 2; | |
color: white; | |
} | |
.key:hover { | |
opacity: 0.9; | |
} | |
.key.white.active { | |
background: linear-gradient(180deg, #d1d5db, #9ca3af); | |
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.3); | |
} | |
.key.black.active { | |
background: linear-gradient(180deg, #4b5563, #111827); | |
box-shadow: inset 0 0 5px rgba(255, 255, 255, 0.1); | |
} | |
.patch-cable { | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
pointer-events: none; | |
} | |
.oscilloscope { | |
width: 100%; | |
height: 100px; | |
background-color: #111827; | |
border: 1px solid var(--accent-color); | |
position: relative; | |
overflow: hidden; | |
} | |
.waveform { | |
position: absolute; | |
bottom: 50%; | |
left: 0; | |
width: 100%; | |
height: 2px; | |
background-color: var(--highlight-color); | |
transform-origin: left center; | |
} | |
.lfo-indicator { | |
position: absolute; | |
top: 50%; | |
left: 0; | |
width: 10px; | |
height: 10px; | |
background-color: #f59e0b; | |
border-radius: 50%; | |
transform: translate(-5px, -5px); | |
z-index: 10; | |
} | |
.cable-port { | |
width: 12px; | |
height: 12px; | |
border-radius: 50%; | |
background-color: #111827; | |
border: 2px solid var(--accent-color); | |
cursor: pointer; | |
position: relative; | |
z-index: 5; | |
} | |
.cable-port.active { | |
background-color: var(--highlight-color); | |
} | |
@keyframes blink { | |
0%, 100% { opacity: 1; } | |
50% { opacity: 0.5; } | |
} | |
.blink { | |
animation: blink 1s infinite; | |
} | |
</style> | |
</head> | |
<body class="min-h-screen flex flex-col items-center justify-center p-4"> | |
<div class="w-full max-w-5xl"> | |
<div class="synth-panel rounded-xl p-6 mb-8"> | |
<h1 class="text-3xl font-bold mb-2 text-center text-violet-400">MODULAR 66</h1> | |
<p class="text-xs text-gray-400 text-center mb-6">ANALOGUE MONOPHONIC SYNTHESIZER</p> | |
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> | |
<!-- VCO Section --> | |
<div class="bg-gray-900/50 p-4 rounded-lg border border-gray-700"> | |
<h2 class="text-lg mb-3 text-violet-300 border-b border-gray-700 pb-2 flex items-center"> | |
<i class="fas fa-wave-square mr-2"></i> VCO | |
</h2> | |
<div class="grid grid-cols-3 gap-4"> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">WAVE</label> | |
<div class="flex flex-col space-y-2"> | |
<div class="button" data-wave="sine" title="Sine"> | |
<i class="fas fa-wave-sine"></i> | |
</div> | |
<div class="button" data-wave="square" title="Square"> | |
<i class="fas fa-wave-square"></i> | |
</div> | |
<div class="button" data-wave="sawtooth" title="Sawtooth"> | |
<i class="fas fa-wave-triangle"></i> | |
</div> | |
</div> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">OCT</label> | |
<div class="knob" id="octave-knob"></div> | |
<span class="text-xs mt-1" id="octave-value">0</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">DETUNE</label> | |
<div class="knob" id="detune-knob"></div> | |
<span class="text-xs mt-1" id="detune-value">0</span> | |
</div> | |
</div> | |
</div> | |
<!-- VCF Section --> | |
<div class="bg-gray-900/50 p-4 rounded-lg border border-gray-700"> | |
<h2 class="text-lg mb-3 text-violet-300 border-b border-gray-700 pb-2 flex items-center"> | |
<i class="fas fa-filter mr-2"></i> VCF | |
</h2> | |
<div class="grid grid-cols-3 gap-4"> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">CUTOFF</label> | |
<div class="knob" id="cutoff-knob"></div> | |
<span class="text-xs mt-1" id="cutoff-value">1000</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">RES</label> | |
<div class="knob" id="resonance-knob"></div> | |
<span class="text-xs mt-1" id="resonance-value">0.5</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">ENV</label> | |
<div class="knob" id="filter-env-knob"></div> | |
<span class="text-xs mt-1" id="filter-env-value">0.5</span> | |
</div> | |
</div> | |
</div> | |
<!-- ENV Section --> | |
<div class="bg-gray-900/50 p-4 rounded-lg border border-gray-700"> | |
<h2 class="text-lg mb-3 text-violet-300 border-b border-gray-700 pb-2 flex items-center"> | |
<i class="fas fa-project-diagram mr-2"></i> ENVELOPE | |
</h2> | |
<div class="grid grid-cols-4 gap-2"> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">A</label> | |
<div class="knob small" id="attack-knob"></div> | |
<span class="text-xs mt-1" id="attack-value">0.1</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">D</label> | |
<div class="knob small" id="decay-knob"></div> | |
<span class="text-xs mt-1" id="decay-value">0.3</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">S</label> | |
<div class="knob small" id="sustain-knob"></div> | |
<span class="text-xs mt-1" id="sustain-value">0.5</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">R</label> | |
<div class="knob small" id="release-knob"></div> | |
<span class="text-xs mt-1" id="release-value">1.0</span> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- LFO Section --> | |
<div class="mt-6 bg-gray-900/50 p-4 rounded-lg border border-gray-700"> | |
<h2 class="text-lg mb-3 text-violet-300 border-b border-gray-700 pb-2 flex items-center"> | |
<i class="fas fa-asterisk mr-2 blink"></i> LFO | |
</h2> | |
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">WAVE</label> | |
<div class="flex space-x-2"> | |
<div class="button" data-lfo="sine" title="Sine"> | |
<i class="fas fa-wave-sine"></i> | |
</div> | |
<div class="button" data-lfo="square" title="Square"> | |
<i class="fas fa-wave-square"></i> | |
</div> | |
</div> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">RATE</label> | |
<div class="knob" id="lfo-rate-knob"></div> | |
<span class="text-xs mt-1" id="lfo-rate-value">1.0</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">AMOUNT</label> | |
<div class="knob" id="lfo-amount-knob"></div> | |
<span class="text-xs mt-1" id="lfo-amount-value">0.5</span> | |
</div> | |
<div class="flex flex-col items-center"> | |
<label class="text-xs mb-1">TARGET</label> | |
<select id="lfo-target" class="bg-gray-800 text-white text-xs p-1 rounded border border-gray-700 w-full"> | |
<option value="cutoff">Cutoff</option> | |
<option value="detune">Detune</option> | |
</select> | |
</div> | |
</div> | |
</div> | |
<!-- Oscilloscope Display --> | |
<div class="mt-6 flex flex-col"> | |
<div class="oscilloscope rounded-t-lg" id="oscilloscope"> | |
<div class="waveform"></div> | |
<div class="lfo-indicator"></div> | |
</div> | |
<div class="bg-gray-900/50 p-2 rounded-b-lg border border-gray-700 border-t-0 flex justify-between"> | |
<span class="text-xs text-violet-300">SIGNAL OUTPUT</span> | |
<div class="flex items-center space-x-2"> | |
<div class="cable-port" data-target="vcf"></div> | |
<div class="cable-port" data-target="lfo"></div> | |
</div> | |
</div> | |
</div> | |
<!-- Keyboard --> | |
<div class="mt-6 flex h-32 bg-gray-900 rounded-b-lg"> | |
<div class="key white" data-note="C"><span>C</span></div> | |
<div class="key black" data-note="C#"></div> | |
<div class="key white" data-note="D"><span>D</span></div> | |
<div class="key black" data-note="D#"></div> | |
<div class="key white" data-note="E"><span>E</span></div> | |
<div class="key white" data-note="F"><span>F</span></div> | |
<div class="key black" data-note="F#"></div> | |
<div class="key white" data-note="G"><span>G</span></div> | |
<div class="key black" data-note="G#"></div> | |
<div class="key white" data-note="A"><span>A</span></div> | |
<div class="key black" data-note="A#"></div> | |
<div class="key white" data-note="B"><span>B</span></div> | |
<div class="key white" data-note="C" data-octave="1"><span>C</span></div> | |
</div> | |
</div> | |
</div> | |
<div class="text-xs text-gray-400 mt-4"> | |
MODULAR 66 ANALOG SYNTHESIZER EMULATOR • USE KEYBOARD WITH QWERTY: A,S,D...E,R...U,I,O | |
</div> | |
<svg id="patch-cable" class="patch-cable"> | |
<path stroke="var(--accent-color)" stroke-width="2" fill="none" /> | |
</svg> | |
<script> | |
document.addEventListener('DOMContentLoaded', function() { | |
// Initialize Tone.js | |
const synth = new Tone.MonoSynth({ | |
oscillator: { | |
type: "sine" | |
}, | |
envelope: { | |
attack: 0.1, | |
decay: 0.3, | |
sustain: 0.5, | |
release: 1 | |
}, | |
filter: { | |
Q: 1, | |
type: "lowpass", | |
rolloff: -12 | |
}, | |
filterEnvelope: { | |
attack: 0.001, | |
decay: 0.2, | |
sustain: 0.5, | |
release: 0.2, | |
baseFrequency: 300, | |
octaves: 4 | |
} | |
}).toDestination(); | |
// LFO | |
const lfo = new Tone.LFO({ | |
type: "sine", | |
frequency: 1, | |
min: 0, | |
max: 1 | |
}).start(); | |
// Set up keyboard mapping | |
const keyMap = { | |
'a': 'C', | |
'w': 'C#', | |
's': 'D', | |
'e': 'D#', | |
'd': 'E', | |
'f': 'F', | |
't': 'F#', | |
'g': 'G', | |
'y': 'G#', | |
'h': 'A', | |
'u': 'A#', | |
'j': 'B', | |
'k': 'C1' | |
}; | |
// Current state | |
let currentWave = "sine"; | |
let currentLfoWave = "sine"; | |
let lfoTarget = "cutoff"; | |
let activeKeys = {}; | |
let draggingCable = false; | |
let cableStart = null; | |
// DOM elements | |
const keys = document.querySelectorAll('.key'); | |
const waveButtons = document.querySelectorAll('[data-wave]'); | |
const lfoButtons = document.querySelectorAll('[data-lfo]'); | |
const cablePorts = document.querySelectorAll('.cable-port'); | |
const patchCable = document.querySelector('#patch-cable path'); | |
// Knob elements and their ranges | |
const knobs = { | |
'octave': { element: document.getElementById('octave-knob'), valueElement: document.getElementById('octave-value'), min: -2, max: 2, step: 1, value: 0 }, | |
'detune': { element: document.getElementById('detune-knob'), valueElement: document.getElementById('detune-value'), min: -1200, max: 1200, step: 10, value: 0 }, | |
'cutoff': { element: document.getElementById('cutoff-knob'), valueElement: document.getElementById('cutoff-value'), min: 20, max: 20000, value: 1000 }, | |
'resonance': { element: document.getElementById('resonance-knob'), valueElement: document.getElementById('resonance-value'), min: 0.1, max: 10, value: 1 }, | |
'filter-env': { element: document.getElementById('filter-env-knob'), valueElement: document.getElementById('filter-env-value'), min: 0, max: 1, value: 0.5 }, | |
'attack': { element: document.getElementById('attack-knob'), valueElement: document.getElementById('attack-value'), min: 0.001, max: 2, value: 0.1 }, | |
'decay': { element: document.getElementById('decay-knob'), valueElement: document.getElementById('decay-value'), min: 0.001, max: 2, value: 0.3 }, | |
'sustain': { element: document.getElementById('sustain-knob'), valueElement: document.getElementById('sustain-value'), min: 0, max: 1, value: 0.5 }, | |
'release': { element: document.getElementById('release-knob'), valueElement: document.getElementById('release-value'), min: 0.001, max: 4, value: 1 }, | |
'lfo-rate': { element: document.getElementById('lfo-rate-knob'), valueElement: document.getElementById('lfo-rate-value'), min: 0.1, max: 10, value: 1 }, | |
'lfo-amount': { element: document.getElementById('lfo-amount-knob'), valueElement: document.getElementById('lfo-amount-value'), min: 0, max: 1, value: 0.5 } | |
}; | |
// Initialize knobs | |
Object.keys(knobs).forEach(key => { | |
const knob = knobs[key]; | |
let rotation = 0; | |
let isDragging = false; | |
let startY = 0; | |
knob.element.addEventListener('mousedown', (e) => { | |
isDragging = true; | |
startY = e.clientY; | |
knob.element.style.cursor = 'grabbing'; | |
e.preventDefault(); | |
}); | |
document.addEventListener('mousemove', (e) => { | |
if (!isDragging) return; | |
const deltaY = startY - e.clientY; | |
const sensitivity = key === 'octave' ? 50 : 100; | |
rotation += deltaY / sensitivity; | |
// Limit rotation to -150 to 150 degrees | |
rotation = Math.max(-150, Math.min(150, rotation)); | |
// Calculate value based on rotation | |
const normalized = (rotation + 150) / 300; // 0 to 1 | |
const value = knob.min + (normalized * (knob.max - knob.min)); | |
// For octave knob, round to nearest step | |
const finalValue = knob.step ? | |
Math.round(value / knob.step) * knob.step : | |
Number(value.toFixed(3)); | |
knob.value = finalValue; | |
updateKnob(knob.element, rotation); | |
updateSynthParameter(key, finalValue); | |
startY = e.clientY; | |
}); | |
document.addEventListener('mouseup', () => { | |
isDragging = false; | |
knob.element.style.cursor = 'pointer'; | |
}); | |
}); | |
// Update knob visual rotation | |
function updateKnob(element, rotation) { | |
element.style.transform = `rotate(${rotation}deg)`; | |
} | |
// Update synth parameters based on knob values | |
function updateSynthParameter(param, value) { | |
switch(param) { | |
case 'octave': | |
knobs.octave.valueElement.textContent = value; | |
break; | |
case 'detune': | |
synth.oscillator.detune = value; | |
knobs.detune.valueElement.textContent = value; | |
break; | |
case 'cutoff': | |
synth.filter.frequency.value = value; | |
knobs.cutoff.valueElement.textContent = Math.round(value); | |
break; | |
case 'resonance': | |
synth.filter.Q.value = value; | |
knobs.resonance.valueElement.textContent = value.toFixed(1); | |
break; | |
case 'filter-env': | |
synth.filterEnvelope.baseFrequency = 300 + (value * 4000); | |
knobs['filter-env'].valueElement.textContent = value.toFixed(2); | |
break; | |
case 'attack': | |
synth.envelope.attack = value; | |
knobs.attack.valueElement.textContent = value.toFixed(3); | |
break; | |
case 'decay': | |
synth.envelope.decay = value; | |
knobs.decay.valueElement.textContent = value.toFixed(3); | |
break; | |
case 'sustain': | |
synth.envelope.sustain = value; | |
knobs.sustain.valueElement.textContent = value.toFixed(2); | |
break; | |
case 'release': | |
synth.envelope.release = value; | |
knobs.release.valueElement.textContent = value.toFixed(3); | |
break; | |
case 'lfo-rate': | |
lfo.frequency.value = value; | |
knobs['lfo-rate'].valueElement.textContent = value.toFixed(1); | |
break; | |
case 'lfo-amount': | |
lfo.max = value; | |
knobs['lfo-amount'].valueElement.textContent = value.toFixed(2); | |
break; | |
} | |
} | |
// Waveform selection | |
waveButtons.forEach(button => { | |
button.addEventListener('click', () => { | |
waveButtons.forEach(btn => btn.classList.remove('active')); | |
button.classList.add('active'); | |
currentWave = button.dataset.wave; | |
synth.oscillator.type = currentWave; | |
}); | |
}); | |
// LFO controls | |
lfoButtons.forEach(button => { | |
button.addEventListener('click', () => { | |
lfoButtons.forEach(btn => btn.classList.remove('active')); | |
button.classList.add('active'); | |
currentLfoWave = button.dataset.lfo; | |
lfo.type = currentLfoWave; | |
}); | |
}); | |
document.getElementById('lfo-target').addEventListener('change', (e) => { | |
lfoTarget = e.target.value; | |
// Disconnect previous target | |
lfo.disconnect(); | |
// Connect to new target | |
if (lfoTarget === 'cutoff') { | |
lfo.connect(synth.filter.frequency); | |
} else if (lfoTarget === 'detune') { | |
lfo.connect(synth.oscillator.detune); | |
} | |
}); | |
// Keyboard interaction | |
keys.forEach(key => { | |
key.addEventListener('mousedown', () => playNote(key.dataset.note)); | |
key.addEventListener('mouseup', () => releaseNote(key.dataset.note)); | |
key.addEventListener('mouseleave', () => { | |
if (key.classList.contains('active')) { | |
releaseNote(key.dataset.note); | |
} | |
}); | |
}); | |
// Computer keyboard interaction | |
document.addEventListener('keydown', (e) => { | |
if (keyMap[e.key] && !activeKeys[e.key]) { | |
activeKeys[e.key] = true; | |
const note = keyMap[e.key]; | |
const keyElement = document.querySelector(`.key[data-note="${note}"]`); | |
if (keyElement) { | |
keyElement.classList.add('active'); | |
playNote(note); | |
} | |
} | |
}); | |
document.addEventListener('keyup', (e) => { | |
if (keyMap[e.key]) { | |
const note = keyMap[e.key]; | |
const keyElement = document.querySelector(`.key[data-note="${note}"]`); | |
if (keyElement) { | |
keyElement.classList.remove('active'); | |
releaseNote(note); | |
} | |
delete activeKeys[e.key]; | |
} | |
}); | |
// Play a note | |
function playNote(note) { | |
let octave = 4; | |
if (note.endsWith('1')) { | |
note = note.replace('1', ''); | |
octave = 5; | |
} | |
synth.triggerAttack(`${note}${octave}`); | |
// Visual feedback for active note | |
const activeNoteIndicator = document.createElement('div'); | |
activeNoteIndicator.className = 'absolute top-0 left-0 w-full h-full bg-violet-400/20 pointer-events-none'; | |
document.querySelector(`.key[data-note="${note}"${note.endsWith('1') ? ' data-octave="1"' : ''}]`) | |
.appendChild(activeNoteIndicator); | |
setTimeout(() => { | |
activeNoteIndicator.style.opacity = '0'; | |
setTimeout(() => activeNoteIndicator.remove(), 300); | |
}, 50); | |
} | |
// Release a note | |
function releaseNote(note) { | |
let octave = 4; | |
if (note.endsWith('1')) { | |
note = note.replace('1', ''); | |
octave = 5; | |
} | |
synth.triggerRelease(); | |
} | |
// Patch cable interaction | |
cablePorts.forEach(port => { | |
port.addEventListener('mousedown', (e) => { | |
draggingCable = true; | |
cableStart = { | |
x: e.clientX, | |
y: e.clientY, | |
element: port | |
}; | |
port.classList.add('active'); | |
e.stopPropagation(); | |
}); | |
}); | |
document.addEventListener('mousemove', (e) => { | |
if (!draggingCable) return; | |
const startRect = cableStart.element.getBoundingClientRect(); | |
const startX = startRect.left + startRect.width / 2; | |
const startY = startRect.top + startRect.height / 2; | |
// Update cable path | |
patchCable.setAttribute('d', `M${startX},${startY} L${e.clientX},${e.clientY}`); | |
}); | |
document.addEventListener('mouseup', (e) => { | |
if (!draggingCable) return; | |
// Find if we're dropping on another port | |
const endElement = document.elementFromPoint(e.clientX, e.clientY); | |
const endPort = endElement?.closest('.cable-port'); | |
if (endPort && endPort !== cableStart.element) { | |
// Connect between these ports (in a real app, we'd implement the actual connection) | |
const startRect = cableStart.element.getBoundingClientRect(); | |
const endRect = endPort.getBoundingClientRect(); | |
const startX = startRect.left + startRect.width / 2; | |
const startY = startRect.top + startRect.height / 2; | |
const endX = endRect.left + endRect.width / 2; | |
const endY = endRect.top + endRect.height / 2; | |
patchCable.setAttribute('d', `M${startX},${startY} L${endX},${endY}`); | |
// Visual feedback for connected ports | |
cableStart.element.classList.add('active'); | |
endPort.classList.add('active'); | |
} else { | |
// Reset cable if not dropped on another port | |
patchCable.setAttribute('d', ''); | |
cableStart.element.classList.remove('active'); | |
} | |
draggingCable = false; | |
cableStart = null; | |
}); | |
// Oscilloscope visualization | |
const waveform = document.querySelector('.waveform'); | |
const lfoIndicator = document.querySelector('.lfo-indicator'); | |
function updateVisualization() { | |
const now = Tone.now(); | |
const lfoValue = lfo.getValueAtTime(now); | |
const lfoPos = (lfoValue + 1) / 2 * 100; // Convert to 0-100% | |
lfoIndicator.style.left = `${lfoPos}%`; | |
// Very simple waveform visualization (in a real app, use Tone.Visualizer) | |
const sinValue = Math.sin(now * 10) * 40; | |
waveform.style.transform = `translateY(${sinValue}px)`; | |
requestAnimationFrame(updateVisualization); | |
} | |
// Start with sine wave selected | |
document.querySelector('[data-wave="sine"]').click(); | |
document.querySelector('[data-lfo="sine"]').click(); | |
// Start visualization | |
updateVisualization(); | |
// Set initial knob rotations based on default values | |
Object.keys(knobs).forEach(key => { | |
const knob = knobs[key]; | |
const normalized = (knob.value - knob.min) / (knob.max - knob.min); | |
const rotation = (normalized * 300) - 150; // -150 to 150 degrees | |
updateKnob(knob.element, rotation); | |
}); | |
}); | |
</script> | |
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - <a href="https://enzostvs-deepsite.hf.space?remix=fluxthedev/modular-66" style="color: #fff;text-decoration: underline;" target="_blank" >🧬 Remix</a></p></body> | |
</html> |