Last active
February 23, 2025 15:36
-
-
Save NickCulbertson/8ebe09c354b04b95bdb389626b126690 to your computer and use it in GitHub Desktop.
AI Synth in 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
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>AI synth</title> | |
<style> | |
/* Global Styles & Background */ | |
body { | |
margin: 0; | |
padding: 0; | |
min-height: 100vh; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
background: linear-gradient(135deg, #0d0d0d, #1a0133); | |
color: #fff; | |
font-family: 'Arial', sans-serif; | |
overflow: hidden; | |
perspective: 1000px; | |
} | |
.background { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background: linear-gradient(0deg, #000 0%, #1a0133 50%, #4a0f99 100%); | |
z-index: -2; | |
} | |
.grid { | |
position: fixed; | |
width: 200%; | |
height: 200%; | |
transform: rotateX(60deg) translateY(-50%) translateZ(-200px); | |
background: | |
linear-gradient(90deg, rgba(138,43,226,0.3) 1px, transparent 1px) 0 0 / 40px 40px, | |
linear-gradient(rgba(138,43,226,0.3) 1px, transparent 1px) 0 0 / 40px 40px; | |
animation: grid-move 20s linear infinite; | |
z-index: -1; | |
} | |
@keyframes grid-move { | |
from { transform: rotateX(60deg) translateY(-50%) translateZ(-200px) translateY(0); } | |
to { transform: rotateX(60deg) translateY(-50%) translateZ(-200px) translateY(40px); } | |
} | |
h1 { | |
font-size: 3.5em; | |
margin-bottom: 20px; | |
text-shadow: | |
0 0 10px rgba(255,255,255,0.8), | |
0 0 20px rgba(255,0,255,0.8), | |
0 0 30px rgba(255,0,255,0.6), | |
0 0 40px rgba(255,0,255,0.4); | |
animation: glow 2s ease-in-out infinite alternate; | |
} | |
@keyframes glow { | |
from { text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 30px #e60073, 0 0 40px #e60073; } | |
to { text-shadow: 0 0 20px #fff, 0 0 30px #ff4da6, 0 0 40px #ff4da6, 0 0 50px #ff4da6; } | |
} | |
/* Controls Panel */ | |
.controls { | |
display: flex; | |
flex-wrap: wrap; | |
justify-content: center; | |
gap: 15px; | |
margin-bottom: 20px; | |
z-index: 1; | |
} | |
.control-group { | |
background: rgba(255, 255, 255, 0.1); | |
padding: 10px 15px; | |
border-radius: 10px; | |
backdrop-filter: blur(5px); | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
font-size: 0.9em; | |
} | |
.control-group label { | |
margin-bottom: 5px; | |
} | |
.control-group input[type="range"], | |
.control-group select { | |
-webkit-appearance: none; | |
width: 120px; | |
background: transparent; | |
} | |
.control-group input[type="range"]::-webkit-slider-thumb { | |
-webkit-appearance: none; | |
height: 16px; | |
width: 16px; | |
border-radius: 50%; | |
background: #ff00ff; | |
cursor: pointer; | |
border: 1px solid #fff; | |
margin-top: -5px; | |
} | |
.control-group input[type="range"]::-webkit-slider-runnable-track { | |
height: 6px; | |
background: #444; | |
border-radius: 3px; | |
} | |
.control-group span { | |
margin-top: 5px; | |
font-size: 0.8em; | |
} | |
/* Piano & Visualizer */ | |
.piano-container { | |
background: rgba(0, 0, 0, 0.5); | |
padding: 30px; | |
border-radius: 15px; | |
box-shadow: 0 0 30px rgba(138,43,226,0.5), | |
inset 0 0 20px rgba(138,43,226,0.3); | |
backdrop-filter: blur(10px); | |
z-index: 1; | |
} | |
.piano { | |
display: flex; | |
position: relative; | |
user-select: none; | |
margin: 0 auto; | |
} | |
.octave { | |
position: relative; | |
display: flex; | |
} | |
.white-key { | |
width: 60px; | |
height: 200px; | |
background: linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(240,240,240,0.95) 100%); | |
border: 1px solid rgba(0,0,0,0.2); | |
border-radius: 0 0 6px 6px; | |
margin: 0 1px; | |
cursor: pointer; | |
position: relative; | |
transition: all 0.1s; | |
overflow: hidden; | |
} | |
.white-key::after { | |
content: ''; | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
top: 0; | |
left: 0; | |
background: linear-gradient(90deg, #ff00ff, #00ffff); | |
opacity: 0; | |
transition: opacity 0.1s; | |
} | |
.white-key:hover::after { opacity: 0.5; } | |
.white-key.active::after { opacity: 1; } | |
.black-key { | |
position: absolute; | |
width: 40px; | |
height: 120px; | |
background: linear-gradient(180deg, #000 0%, #222 100%); | |
border-radius: 0 0 4px 4px; | |
z-index: 2; | |
cursor: pointer; | |
transition: all 0.1s; | |
box-shadow: -1px 0 2px rgba(255,255,255,0.2); | |
overflow: hidden; | |
} | |
.black-key::after { | |
content: ''; | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
top: 0; | |
left: 0; | |
background: linear-gradient(90deg, #ff00ff, #00ffff); | |
opacity: 0; | |
transition: opacity 0.1s; | |
} | |
.black-key:hover::after { opacity: 0.5; } | |
.black-key.active::after { opacity: 1; } | |
.visualizer { | |
width: 100%; | |
height: 50px; | |
margin-top: 20px; | |
display: flex; | |
gap: 2px; | |
} | |
.visualizer-bar { | |
flex: 1; | |
background: rgba(138,43,226,0.3); | |
transform-origin: bottom; | |
transition: transform 0.1s; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="background"></div> | |
<div class="grid"></div> | |
<h1>AI synth</h1> | |
<!-- Controls Panel --> | |
<div class="controls"> | |
<div class="control-group"> | |
<label for="preset">Preset</label> | |
<select id="preset"> | |
<option value="Pad">Pad</option> | |
<option value="Lead">Lead</option> | |
<option value="Bass">Bass</option> | |
<option value="Brass">Brass</option> | |
<option value="Pluck">Pluck</option> | |
</select> | |
</div> | |
<div class="control-group"> | |
<label for="amp-attack">Amp Attack</label> | |
<input type="range" id="amp-attack" min="0" max="2" step="0.01"> | |
<span id="amp-attack-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="amp-decay">Amp Decay</label> | |
<input type="range" id="amp-decay" min="0" max="2" step="0.01"> | |
<span id="amp-decay-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="amp-sustain">Amp Sustain</label> | |
<input type="range" id="amp-sustain" min="0" max="1" step="0.01"> | |
<span id="amp-sustain-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="amp-release">Amp Release</label> | |
<input type="range" id="amp-release" min="0" max="3" step="0.01"> | |
<span id="amp-release-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="filter-cutoff">Filter Cutoff</label> | |
<input type="range" id="filter-cutoff" min="100" max="5000" step="1"> | |
<span id="filter-cutoff-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="filter-resonance">Filter Resonance</label> | |
<input type="range" id="filter-resonance" min="0" max="10" step="0.1"> | |
<span id="filter-resonance-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="filter-attack">Filter Env Attack</label> | |
<input type="range" id="filter-attack" min="0" max="2" step="0.01"> | |
<span id="filter-attack-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="filter-decay">Filter Env Decay</label> | |
<input type="range" id="filter-decay" min="0" max="2" step="0.01"> | |
<span id="filter-decay-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="filter-sustain">Filter Env Sustain</label> | |
<input type="range" id="filter-sustain" min="0" max="1" step="0.01"> | |
<span id="filter-sustain-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="filter-release">Filter Env Release</label> | |
<input type="range" id="filter-release" min="0" max="3" step="0.01"> | |
<span id="filter-release-val"></span> | |
</div> | |
<div class="control-group"> | |
<label for="detune">Detune (cents)</label> | |
<input type="range" id="detune" min="0" max="20" step="1"> | |
<span id="detune-val"></span> | |
</div> | |
<!-- Oscillator Volume Controls --> | |
<div class="control-group"> | |
<label for="osc1-vol">Osc1 Volume</label> | |
<input type="range" id="osc1-vol" min="0" max="1" step="0.01" value="0.33"> | |
<span id="osc1-vol-val">0.33</span> | |
</div> | |
<div class="control-group"> | |
<label for="osc2-vol">Osc2 Volume</label> | |
<input type="range" id="osc2-vol" min="0" max="1" step="0.01" value="0.33"> | |
<span id="osc2-vol-val">0.33</span> | |
</div> | |
<div class="control-group"> | |
<label for="osc3-vol">Osc3 Volume</label> | |
<input type="range" id="osc3-vol" min="0" max="1" step="0.01" value="0.34"> | |
<span id="osc3-vol-val">0.34</span> | |
</div> | |
</div> | |
<!-- Piano & Visualizer --> | |
<div class="piano-container"> | |
<div class="piano" id="piano"></div> | |
<div class="visualizer" id="visualizer"></div> | |
</div> | |
<script> | |
// Preset Definitions (improved) | |
const presets = { | |
"Pad": { | |
ampEnv: { attack: 1.2, decay: 1.5, sustain: 0.8, release: 3.0 }, | |
filter: { cutoff: 1000, resonance: 1.2 }, | |
filterEnv: { attack: 0.7, decay: 0.8, sustain: 0.6, release: 2.0 }, | |
detune: 8 | |
}, | |
"Lead": { | |
ampEnv: { attack: 0.05, decay: 0.2, sustain: 0.9, release: 0.3 }, | |
filter: { cutoff: 2500, resonance: 0.9 }, | |
filterEnv: { attack: 0.1, decay: 0.15, sustain: 0.7, release: 0.4 }, | |
detune: 12 | |
}, | |
"Bass": { | |
ampEnv: { attack: 0.01, decay: 0.15, sustain: 0.7, release: 0.2 }, | |
filter: { cutoff: 600, resonance: 1.5 }, | |
filterEnv: { attack: 0.02, decay: 0.1, sustain: 0.5, release: 0.2 }, | |
detune: 3 | |
}, | |
"Brass": { | |
ampEnv: { attack: 0.1, decay: 0.25, sustain: 0.85, release: 0.5 }, | |
filter: { cutoff: 1800, resonance: 1.0 }, | |
filterEnv: { attack: 0.07, decay: 0.2, sustain: 0.75, release: 0.6 }, | |
detune: 6 | |
}, | |
"Pluck": { | |
ampEnv: { attack: 0.005, decay: 0.12, sustain: 0.0, release: 0.15 }, | |
filter: { cutoff: 2200, resonance: 1.8 }, | |
filterEnv: { attack: 0.002, decay: 0.08, sustain: 0.0, release: 0.1 }, | |
detune: 0 | |
} | |
}; | |
class AISynth { | |
constructor() { | |
this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
this.octaves = 3; | |
this.isMouseDown = false; | |
this.activeVoices = {}; // Map note -> voice object | |
// Set default preset ("Pad") | |
this.setPreset("Pad"); | |
// Create master gain and connect to destination | |
this.masterGain = this.audioContext.createGain(); | |
this.masterGain.gain.value = 0.8; | |
this.masterGain.connect(this.audioContext.destination); | |
// Initialize oscillator volumes from UI | |
this.osc1Vol = parseFloat(document.getElementById('osc1-vol').value); | |
this.osc2Vol = parseFloat(document.getElementById('osc2-vol').value); | |
this.osc3Vol = parseFloat(document.getElementById('osc3-vol').value); | |
this.setupPiano(); | |
this.setupEventListeners(); | |
this.setupVisualizer(); | |
this.updateUI(); | |
} | |
setPreset(presetName) { | |
const preset = presets[presetName]; | |
this.presetName = presetName; | |
// Set synth parameters from preset | |
this.ampEnv = { ...preset.ampEnv }; | |
this.filterCutoff = preset.filter.cutoff; | |
this.filterResonance = preset.filter.resonance; | |
this.filterEnv = { ...preset.filterEnv }; | |
this.detune = preset.detune; | |
// Update UI controls to match preset values | |
document.getElementById('amp-attack').value = this.ampEnv.attack; | |
document.getElementById('amp-decay').value = this.ampEnv.decay; | |
document.getElementById('amp-sustain').value = this.ampEnv.sustain; | |
document.getElementById('amp-release').value = this.ampEnv.release; | |
document.getElementById('filter-cutoff').value = this.filterCutoff; | |
document.getElementById('filter-resonance').value = this.filterResonance; | |
document.getElementById('filter-attack').value = this.filterEnv.attack; | |
document.getElementById('filter-decay').value = this.filterEnv.decay; | |
document.getElementById('filter-sustain').value = this.filterEnv.sustain; | |
document.getElementById('filter-release').value = this.filterEnv.release; | |
document.getElementById('detune').value = this.detune; | |
this.updateUI(); | |
} | |
setupVisualizer() { | |
const visualizer = document.getElementById('visualizer'); | |
for (let i = 0; i < 32; i++) { | |
const bar = document.createElement('div'); | |
bar.className = 'visualizer-bar'; | |
visualizer.appendChild(bar); | |
} | |
} | |
updateVisualizer() { | |
const bars = document.querySelectorAll('.visualizer-bar'); | |
const activeCount = Object.keys(this.activeVoices).length; | |
bars.forEach(bar => { | |
const scale = activeCount > 0 ? (Math.random() * 0.5 + 0.5) : (Math.random() * 0.1 + 0.1); | |
bar.style.transform = `scaleY(${scale})`; | |
}); | |
} | |
setupPiano() { | |
const piano = document.getElementById('piano'); | |
const whiteKeyNames = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; | |
// Define offsets for black keys relative to the white keys | |
const blackKeyOffsets = { 'C#': 40, 'D#': 100, 'F#': 220, 'G#': 280, 'A#': 340 }; | |
for (let octave = 0; octave < this.octaves; octave++) { | |
const octaveDiv = document.createElement('div'); | |
octaveDiv.className = 'octave'; | |
// Create white keys | |
whiteKeyNames.forEach(note => { | |
const key = document.createElement('div'); | |
key.className = 'white-key'; | |
key.dataset.note = note + (octave + 4); | |
octaveDiv.appendChild(key); | |
}); | |
piano.appendChild(octaveDiv); | |
// Create black keys | |
const whiteKeys = octaveDiv.querySelectorAll('.white-key'); | |
// The black key order: C#, D#, F#, G#, A# | |
const positions = [0, 1, 3, 4, 5]; | |
Object.keys(blackKeyOffsets).forEach((note, index) => { | |
const key = document.createElement('div'); | |
key.className = 'black-key'; | |
key.dataset.note = note + (octave + 4); | |
const refKey = whiteKeys[positions[index]]; | |
const leftPos = refKey.offsetLeft + refKey.offsetWidth - 20; | |
key.style.left = leftPos + 'px'; | |
octaveDiv.appendChild(key); | |
}); | |
} | |
} | |
noteToFrequency(note) { | |
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; | |
const regex = /([A-G]#?)(\d)/; | |
const match = note.match(regex); | |
if (!match) return 0; | |
const letter = match[1]; | |
const octave = parseInt(match[2]); | |
const index = notes.indexOf(letter); | |
const midiNumber = index + (octave + 1) * 12; | |
return 440 * Math.pow(2, (midiNumber - 69) / 12); | |
} | |
// Immediately stop any existing voice for the given note | |
_immediateStop(note) { | |
const voice = this.activeVoices[note]; | |
if (!voice) return; | |
voice.oscillators.forEach(osc => { | |
try { osc.stop(0); } catch(e) {} | |
osc.disconnect(); | |
}); | |
voice.ampGain.disconnect(); | |
voice.filter.disconnect(); | |
delete this.activeVoices[note]; | |
// Immediately update key visual state | |
const keyElement = document.querySelector(`[data-note="${note}"]`); | |
if (keyElement) keyElement.classList.remove('active'); | |
} | |
playNote(keyElement) { | |
const note = keyElement.dataset.note; | |
// Immediately stop any previous instance of this note | |
if (this.activeVoices[note]) { | |
this._immediateStop(note); | |
} | |
const frequency = this.noteToFrequency(note); | |
const now = this.audioContext.currentTime; | |
// Create a voice object | |
const voice = {}; | |
// Create a gain node for amplitude envelope | |
const ampGain = this.audioContext.createGain(); | |
ampGain.gain.setValueAtTime(0, now); | |
ampGain.gain.linearRampToValueAtTime(1, now + parseFloat(this.ampEnv.attack)); | |
ampGain.gain.linearRampToValueAtTime(this.ampEnv.sustain, now + parseFloat(this.ampEnv.attack) + parseFloat(this.ampEnv.decay)); | |
// Create a lowpass filter with filter envelope: | |
// Ramp from cutoff to a pronounced peak then decay to sustain level. | |
const filter = this.audioContext.createBiquadFilter(); | |
filter.type = 'lowpass'; | |
filter.Q.value = this.filterResonance; | |
filter.frequency.setValueAtTime(this.filterCutoff, now); | |
// Increased envelope strength: multiply cutoff by 2.5 | |
const filterPeak = this.filterCutoff * 2.5; | |
const filterSustain = this.filterCutoff + (filterPeak - this.filterCutoff) * this.filterEnv.sustain; | |
filter.frequency.linearRampToValueAtTime(filterPeak, now + parseFloat(this.filterEnv.attack)); | |
filter.frequency.linearRampToValueAtTime(filterSustain, now + parseFloat(this.filterEnv.attack) + parseFloat(this.filterEnv.decay)); | |
// Create stacked sawtooth oscillators with individual gain nodes for volume control | |
const osc1 = this.audioContext.createOscillator(); | |
osc1.type = 'sawtooth'; | |
osc1.frequency.setValueAtTime(frequency, now); | |
osc1.detune.value = -this.detune; | |
const osc1Gain = this.audioContext.createGain(); | |
osc1Gain.gain.value = this.osc1Vol; | |
const osc2 = this.audioContext.createOscillator(); | |
osc2.type = 'sawtooth'; | |
osc2.frequency.setValueAtTime(frequency, now); | |
osc2.detune.value = this.detune; | |
const osc2Gain = this.audioContext.createGain(); | |
osc2Gain.gain.value = this.osc2Vol; | |
const osc3 = this.audioContext.createOscillator(); | |
osc3.type = 'sawtooth'; | |
osc3.frequency.setValueAtTime(frequency / 2, now); | |
const osc3Gain = this.audioContext.createGain(); | |
osc3Gain.gain.value = this.osc3Vol; | |
// Connect oscillators through their gain nodes to the amp envelope | |
osc1.connect(osc1Gain); | |
osc1Gain.connect(ampGain); | |
osc2.connect(osc2Gain); | |
osc2Gain.connect(ampGain); | |
osc3.connect(osc3Gain); | |
osc3Gain.connect(ampGain); | |
// Connect amp envelope to filter then to master output | |
ampGain.connect(filter); | |
filter.connect(this.masterGain); | |
// Start oscillators | |
osc1.start(now); | |
osc2.start(now); | |
osc3.start(now); | |
voice.oscillators = [osc1, osc2, osc3]; | |
voice.ampGain = ampGain; | |
voice.filter = filter; | |
voice.startTime = now; | |
this.activeVoices[note] = voice; | |
keyElement.classList.add('active'); | |
} | |
stopNote(note) { | |
const voice = this.activeVoices[note]; | |
if (!voice) return; | |
const now = this.audioContext.currentTime; | |
// Apply amplitude envelope release | |
voice.ampGain.gain.cancelScheduledValues(now); | |
voice.ampGain.gain.setValueAtTime(voice.ampGain.gain.value, now); | |
voice.ampGain.gain.linearRampToValueAtTime(0, now + parseFloat(this.ampEnv.release)); | |
// Apply filter envelope release | |
voice.filter.frequency.cancelScheduledValues(now); | |
voice.filter.frequency.setValueAtTime(voice.filter.frequency.value, now); | |
voice.filter.frequency.linearRampToValueAtTime(this.filterCutoff, now + parseFloat(this.filterEnv.release)); | |
// Calculate precise stop time | |
const stopTime = now + parseFloat(this.ampEnv.release); | |
// Schedule oscillators to stop | |
voice.oscillators.forEach(osc => osc.stop(stopTime)); | |
// Cleanup when the first oscillator finishes | |
voice.oscillators[0].addEventListener('ended', () => { | |
// Ensure voice still exists | |
if (!this.activeVoices[note]) return; | |
// Disconnect all nodes | |
voice.oscillators.forEach(osc => osc.disconnect()); | |
voice.ampGain.disconnect(); | |
voice.filter.disconnect(); | |
// Remove from active voices | |
delete this.activeVoices[note]; | |
// Update key visual state | |
const keyElement = document.querySelector(`[data-note="${note}"]`); | |
if (keyElement) keyElement.classList.remove('active'); | |
}); | |
} | |
stopAllNotes() { | |
Object.keys(this.activeVoices).forEach(note => this.stopNote(note)); | |
document.querySelectorAll('.white-key.active, .black-key.active').forEach(key => key.classList.remove('active')); | |
} | |
setupEventListeners() { | |
// Use pointer events for better drag behavior | |
document.addEventListener('pointerdown', () => this.isMouseDown = true); | |
document.addEventListener('pointerup', () => { | |
this.isMouseDown = false; | |
this.stopAllNotes(); | |
}); | |
document.addEventListener('pointercancel', () => { | |
this.isMouseDown = false; | |
this.stopAllNotes(); | |
}); | |
const keys = document.querySelectorAll('.white-key, .black-key'); | |
keys.forEach(key => { | |
key.addEventListener('pointerdown', (e) => { | |
e.preventDefault(); | |
this.playNote(key); | |
}); | |
key.addEventListener('pointerenter', (e) => { | |
if (this.isMouseDown) this.playNote(key); | |
}); | |
key.addEventListener('pointerleave', (e) => { | |
this.stopNote(key.dataset.note); | |
key.classList.remove('active'); | |
}); | |
key.addEventListener('pointerup', (e) => { | |
this.stopNote(key.dataset.note); | |
key.classList.remove('active'); | |
}); | |
}); | |
document.getElementById('preset').addEventListener('change', (e) => { | |
this.setPreset(e.target.value); | |
}); | |
// Link control inputs to synth parameters | |
const controls = [ | |
{ id: 'amp-attack', prop: 'attack', env: 'ampEnv' }, | |
{ id: 'amp-decay', prop: 'decay', env: 'ampEnv' }, | |
{ id: 'amp-sustain', prop: 'sustain', env: 'ampEnv' }, | |
{ id: 'amp-release', prop: 'release', env: 'ampEnv' }, | |
{ id: 'filter-cutoff', prop: 'filterCutoff' }, | |
{ id: 'filter-resonance', prop: 'filterResonance' }, | |
{ id: 'filter-attack', prop: 'attack', env: 'filterEnv' }, | |
{ id: 'filter-decay', prop: 'decay', env: 'filterEnv' }, | |
{ id: 'filter-sustain', prop: 'sustain', env: 'filterEnv' }, | |
{ id: 'filter-release', prop: 'release', env: 'filterEnv' }, | |
{ id: 'detune', prop: 'detune' }, | |
{ id: 'osc1-vol', prop: 'osc1Vol' }, | |
{ id: 'osc2-vol', prop: 'osc2Vol' }, | |
{ id: 'osc3-vol', prop: 'osc3Vol' } | |
]; | |
controls.forEach(control => { | |
const element = document.getElementById(control.id); | |
element.addEventListener('input', (e) => { | |
const value = parseFloat(e.target.value); | |
if (control.env) { | |
this[control.env][control.prop] = value; | |
} else { | |
this[control.prop] = value; | |
} | |
this.updateUI(); | |
}); | |
}); | |
setInterval(() => this.updateVisualizer(), 50); | |
} | |
updateUI() { | |
document.getElementById('amp-attack-val').textContent = this.ampEnv.attack; | |
document.getElementById('amp-decay-val').textContent = this.ampEnv.decay; | |
document.getElementById('amp-sustain-val').textContent = this.ampEnv.sustain; | |
document.getElementById('amp-release-val').textContent = this.ampEnv.release; | |
document.getElementById('filter-cutoff-val').textContent = this.filterCutoff; | |
document.getElementById('filter-resonance-val').textContent = this.filterResonance; | |
document.getElementById('filter-attack-val').textContent = this.filterEnv.attack; | |
document.getElementById('filter-decay-val').textContent = this.filterEnv.decay; | |
document.getElementById('filter-sustain-val').textContent = this.filterEnv.sustain; | |
document.getElementById('filter-release-val').textContent = this.filterEnv.release; | |
document.getElementById('detune-val').textContent = this.detune; | |
document.getElementById('osc1-vol-val').textContent = this.osc1Vol; | |
document.getElementById('osc2-vol-val').textContent = this.osc2Vol; | |
document.getElementById('osc3-vol-val').textContent = this.osc3Vol; | |
} | |
} | |
const synth = new AISynth(); | |
// Keyboard mapping for computer keys to note names. | |
// For example: a = C4, w = C#4, s = D4, e = D#4, d = E4, f = F4, t = F#4, g = G4, y = G#4, h = A4, u = A#4, j = B4, k = C5, etc. | |
const keyboardMap = { | |
'a': 'C4', | |
'w': 'C#4', | |
's': 'D4', | |
'e': 'D#4', | |
'd': 'E4', | |
'f': 'F4', | |
't': 'F#4', | |
'g': 'G4', | |
'y': 'G#4', | |
'h': 'A4', | |
'u': 'A#4', | |
'j': 'B4', | |
'k': 'C5', | |
'o': 'C#5', | |
'l': 'D5', | |
'p': 'D#5', | |
';': 'E5' | |
}; | |
// Track currently pressed keys to prevent repeats. | |
const pressedKeys = new Set(); | |
window.addEventListener('keydown', (e) => { | |
const key = e.key.toLowerCase(); | |
if (keyboardMap[key] && !pressedKeys.has(key)) { | |
pressedKeys.add(key); | |
const note = keyboardMap[key]; | |
// Immediately stop any previous instance of this note and play new voice. | |
synth._immediateStop(note); | |
// Create a dummy element to pass into playNote. | |
const dummyKey = document.createElement('div'); | |
dummyKey.dataset.note = note; | |
synth.playNote(dummyKey); | |
// Also mark on-screen key as active if available. | |
const onscreen = document.querySelector(`.white-key[data-note="${note}"], .black-key[data-note="${note}"]`); | |
if (onscreen) { | |
onscreen.classList.add('active'); | |
} | |
} | |
}); | |
window.addEventListener('keyup', (e) => { | |
const key = e.key.toLowerCase(); | |
if (keyboardMap[key]) { | |
pressedKeys.delete(key); | |
const note = keyboardMap[key]; | |
synth.stopNote(note); | |
const onscreen = document.querySelector(`.white-key[data-note="${note}"], .black-key[data-note="${note}"]`); | |
if (onscreen) { | |
onscreen.classList.remove('active'); | |
} | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment