Skip to content

Instantly share code, notes, and snippets.

@NickCulbertson
Last active February 23, 2025 15:36
Show Gist options
  • Save NickCulbertson/8ebe09c354b04b95bdb389626b126690 to your computer and use it in GitHub Desktop.
Save NickCulbertson/8ebe09c354b04b95bdb389626b126690 to your computer and use it in GitHub Desktop.
AI Synth in html
<!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