ASSISTANT
It sounds like you want a **chiptune music tracker** implemented with HTML, CSS, and JavaScript featuring:
- BPM control
- Patterns and rows per pattern
- Multiple instruments, each with its own synth settings
- Custom waveform selection per instrument (oscillator types)
- Volume, detune, ADSR envelope, filter, noise controls per instrument
- Ability to share songs (probably via URL hash or JSON export)
- Keyboard shortcuts (for efficiency)
- Editing features like duplicate, delete, copy, paste, loop patterns
- No use of demos or external libraries for actual playback (self-contained)
---
I can outline and provide a minimal but extendable example implementing these requirements or assist you with a fully functional prototype step-by-step.
---
### Let's build a minimal prototype chiptune tracker with the following features:
- **Grid UI:** Patterns and rows to input notes (using keyboard or mouse)
- **Instruments:** Multiple instruments with custom waveform, volume, detune, ADSR, filter, noise toggle
- **Playback:** Simple playback engine using Web Audio API oscillator nodes and gain nodes
- **BPM:** Adjustable BPM that controls playback speed
- **Patterns:** Can create, delete, duplicate patterns
- **Shortcuts:** Basic shortcuts for editing pattern rows
- **Sharing:** Export/import song data via URL hash JSON string
---
### Due to complexity, I'll provide a working minimal tracker you can improve later.
---
### Implementation
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chiptune Tracker</title>
<style>
body {
font-family: monospace;
background: #111;
color: #eee;
margin: 0; padding: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background: #222;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 1rem;
}
main {
flex: 1;
display: flex;
overflow: hidden;
}
#pattern-editor {
flex: 1;
background: #222;
overflow: auto;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
user-select: none;
}
td, th {
border: 1px solid #444;
text-align: center;
padding: 4px 2px;
cursor: pointer;
color: #ccc;
}
td.selected {
background: #444;
color: #0f0;
}
td.editing {
outline: 2px solid #0f0;
}
#controls {
width: 300px;
background: #222;
padding: 12px;
overflow-y: auto;
}
label {
display: block;
margin: 6px 0 2px 0;
font-weight: bold;
}
input[type=range], select, input[type=number] {
width: 100%;
}
button {
margin-top: 8px;
padding: 6px 8px;
background: #333;
border: none;
color: #eee;
cursor: pointer;
}
button:hover {
background: #666;
}
</style>
</head>
<body>
<header>
<label>BPM: <input type="number" id="bpm" value="120" min="30" max="400" step="1" style="width:60px" /></label>
<button id="playBtn">▶ Play</button>
<button id="stopBtn">■ Stop</button>
<button id="addPattern">+ Pattern</button>
<button id="delPattern">- Pattern</button>
<button id="copyPattern">Copy Pattern</button>
<button id="pastePattern">Paste Pattern</button>
<button id="shareSong">Share Song</button>
<select id="instrumentSelect"></select>
<button id="addInstrument">+ Instrument</button>
<button id="delInstrument">- Instrument</button>
</header>
<main>
<div id="pattern-editor"></div>
<div id="controls">
<h3>Instrument Controls</h3>
<label for="waveform">Waveform</label>
<select id="waveform">
<option value="square">Square</option>
<option value="sawtooth">Sawtooth</option>
<option value="triangle">Triangle</option>
<option value="sine">Sine</option>
</select>
<label for="volume">Volume</label>
<input type="range" id="volume" min="0" max="1" step="0.01" value="0.5" />
<label for="detune">Detune (cents)</label>
<input type="range" id="detune" min="-100" max="100" step="1" value="0" />
<label>ADSR Envelope</label>
<label for="attack">Attack (s)</label>
<input type="range" id="attack" min="0" max="1" step="0.01" value="0.01" />
<label for="decay">Decay (s)</label>
<input type="range" id="decay" min="0" max="1" step="0.01" value="0.1" />
<label for="sustain">Sustain (0-1)</label>
<input type="range" id="sustain" min="0" max="1" step="0.01" value="0.7" />
<label for="release">Release (s)</label>
<input type="range" id="release" min="0" max="1" step="0.01" value="0.1" />
<label for="filterFreq">Filter Frequency (Hz)</label>
<input type="range" id="filterFreq" min="100" max="10000" step="10" value="8000" />
<label><input type="checkbox" id="noiseEnable"> Noise</label>
</div>
</main>
<script>
(() => {
// Constants
const ROWS_PER_PATTERN = 16;
const MAX_INSTRUMENTS = 4;
// Notes lookup - we'll use note names + octave e.g. C4
const NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
function noteToFreq(note) {
if (!note) return 0;
// Parse note like C4 or D#5
const match = note.match(/^([A-G]#?)(\d)$/);
if(!match) return 0;
const [ , pitch, octave ] = match;
const pitchIndex = NOTES.indexOf(pitch);
if(pitchIndex < 0) return 0;
// f = 440 * 2^((n-69)/12)
// n = MIDI note number
const midiNote = (parseInt(octave))*12 + pitchIndex;
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
// Default instrument data
function createDefaultInstrument(name = "Inst") {
return {
name,
waveform: 'square',
volume: 0.5,
detune: 0,
adsr: { attack: 0.01, decay: 0.1, sustain: 0.7, release: 0.1 },
filterFreq: 8000,
noiseEnable: false,
};
}
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let instruments = [createDefaultInstrument("Instrument 1")];
let currentInstrumentIndex = 0;
// Song data structure
const song = {
bpm: 120,
instruments,
patterns: [],
currentPattern: 0,
};
// Pattern is an array of rows, each row has a note column (per instrument)
// We'll store array of patterns, each pattern has rows, each row has notes
// For simplicity right now one note column per pattern and instrument
// But we'll allow multiple instruments playing simultaneously.
function createEmptyPattern() {
const pattern = [];
for (let i = 0; i < ROWS_PER_PATTERN; i++) {
// For each row store notes for each instrument
const row = [];
for (let inst = 0; inst < instruments.length; inst++) {
row.push(null); // null note
}
pattern.push(row);
}
return pattern;
}
// Initialize with 1 empty pattern
song.patterns.push(createEmptyPattern());
// UI elements
const bpmInput = document.getElementById('bpm');
const playBtn = document.getElementById('playBtn');
const stopBtn = document.getElementById('stopBtn');
const addPatternBtn = document.getElementById('addPattern');
const delPatternBtn = document.getElementById('delPattern');
const copyPatternBtn = document.getElementById('copyPattern');
const pastePatternBtn = document.getElementById('pastePattern');
const shareSongBtn = document.getElementById('shareSong');
const instrumentSelect = document.getElementById('instrumentSelect');
const addInstrumentBtn = document.getElementById('addInstrument');
const delInstrumentBtn = document.getElementById('delInstrument');
const patternEditor = document.getElementById('pattern-editor');
// Instrument controls
const waveformSelect = document.getElementById('waveform');
const volumeRange = document.getElementById('volume');
const detuneRange = document.getElementById('detune');
const attackRange = document.getElementById('attack');
const decayRange = document.getElementById('decay');
const sustainRange = document.getElementById('sustain');
const releaseRange = document.getElementById('release');
const filterFreqRange = document.getElementById('filterFreq');
const noiseEnableCheckbox = document.getElementById('noiseEnable');
// Variables for editing
let selectedRow = 0;
let selectedCol = currentInstrumentIndex; // instrument column selected
let editing = false;
// Clipboard for pattern copy/paste
let copiedPattern = null;
// Playback variables
let isPlaying = false;
let currentPlayRow = 0;
let playIntervalId = null;
// Helpers for rendering pattern editor table
// Display notes as string or "---"
function noteToString(note) {
return note || "---";
}
function renderInstrumentList() {
instrumentSelect.innerHTML = '';
song.instruments.forEach((inst, idx) => {
const option = document.createElement('option');
option.value = idx;
option.textContent = inst.name || 'Instrument ' + (idx + 1);
instrumentSelect.appendChild(option);
});
instrumentSelect.value = currentInstrumentIndex;
}
// Render pattern grid for current pattern
function renderPatternEditor() {
const pattern = song.patterns[song.currentPattern];
const instCount = song.instruments.length;
// Clear previous content
patternEditor.innerHTML = '';
// Create table with rows and columns
const table = document.createElement('table');
// Header row
let thead = document.createElement('thead');
let tr = document.createElement('tr');
let thRow = document.createElement('th');
thRow.textContent = "Row";
tr.appendChild(thRow);
for(let i = 0; i < instCount; i++) {
const th = document.createElement('th');
th.textContent = `Inst ${i+1}`;
tr.appendChild(th);
}
thead.appendChild(tr);
table.appendChild(thead);
// Body rows
let tbody = document.createElement('tbody');
for(let r = 0; r < ROWS_PER_PATTERN; r++) {
const tr = document.createElement('tr');
const th = document.createElement('th');
th.textContent = r;
tr.appendChild(th);
for (let i = 0; i < instCount; i++) {
const td = document.createElement('td');
const note = pattern[r][i];
td.textContent = noteToString(note);
// Highlight selected cell
if (r === selectedRow && i === selectedCol) {
td.classList.add('selected');
if(editing) td.classList.add('editing');
}
// Click select cell
td.addEventListener('click', () => {
selectedRow = r;
selectedCol = i;
editing = false;
renderPatternEditor();
focusNoteInput();
});
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
patternEditor.appendChild(table);
}
// Keyboard control for note entry and navigation
// We'll support notes C0-B8 (full MIDI range)
// Map keys to notes
// Using computer QWERTY keyboard as piano keys:
// rows: z/s,x/d,c/f,v/g,b,h,n,j,m,comma,l,period,semicolon,slash (just example)
// Simplify: use A-G keys plus octave select via keys
const noteKeys = {
'a': 'C',
'w': 'C#',
's': 'D',
'e': 'D#',
'd': 'E',
'f': 'F',
't': 'F#',
'g': 'G',
'y': 'G#',
'h': 'A',
'u': 'A#',
'j': 'B'
};
let currentOctave = 4;
function focusNoteInput() {
// Nothing for now - could add a hidden input to capture keys
}
function sanitizeNoteInput(note) {
if (!note) return null;
if(note.length < 2) return null;
const pitch = note.slice(0, -1).toUpperCase();
const octave = parseInt(note.slice(-1));
if(isNaN(octave)) return null;
if(!NOTES.includes(pitch)) return null;
return pitch + octave;
}
// Handle keyboard commands
window.addEventListener('keydown', e => {
if(editing) {
// Enter note input mode
if(e.key === "Escape") {
// Cancel edit
editing = false;
renderPatternEditor();
e.preventDefault();
return;
}
// Letters for notes
if(noteKeys[e.key]) {
const note = noteKeys[e.key] + currentOctave;
setCurrentNote(note);
moveDown();
renderPatternEditor();
e.preventDefault();
return;
}
// Octave change keys
if(e.key === 'ArrowUp') {
currentOctave = Math.min(8, currentOctave + 1);
e.preventDefault();
return;
}
if(e.key === 'ArrowDown') {
currentOctave = Math.max(0, currentOctave - 1);
e.preventDefault();
return;
}
if(e.key === '-') {
// Clear note
setCurrentNote(null);
moveDown();
renderPatternEditor();
e.preventDefault();
return;
}
if(e.key === 'Enter') {
moveDown();
renderPatternEditor();
e.preventDefault();
return;
}
} else {
// Control keys when not editing
if(e.key === "Enter") {
editing = true;
renderPatternEditor();
e.preventDefault();
return;
}
if(e.key === "ArrowDown") {
selectedRow = Math.min(ROWS_PER_PATTERN - 1, selectedRow + 1);
renderPatternEditor();
e.preventDefault();
return;
}
if(e.key === "ArrowUp") {
selectedRow = Math.max(0, selectedRow - 1);
renderPatternEditor();
e.preventDefault();
return;
}
if(e.key === "ArrowLeft") {
selectedCol = Math.max(0, selectedCol - 1);
renderPatternEditor();
e.preventDefault();
return;
}
if(e.key === "ArrowRight") {
selectedCol = Math.min(song.instruments.length -1, selectedCol + 1);
renderPatternEditor();
e.preventDefault();
return;
}
// Shortcuts
if(e.ctrlKey) {
if(e.key === 'c') {
// Copy row
copyRow(selectedRow);
e.preventDefault();
return;
}
if(e.key === 'v') {
// Paste row
pasteRow(selectedRow);
e.preventDefault();
return;
}
if(e.key === 'd') {
// Duplicate row
duplicateRow(selectedRow);
e.preventDefault();
return;
}
if(e.key === 'Delete') {
// Delete row
deleteRow(selectedRow);
e.preventDefault();
return;
}
}
}
});
function setCurrentNote(note) {
const pattern = song.patterns[song.currentPattern];
pattern[selectedRow][selectedCol] = note;
}
function moveDown() {
selectedRow++;
if(selectedRow >= ROWS_PER_PATTERN) selectedRow = 0;
}
// Copy/Paste row clipboard
let rowClipboard = null;
function copyRow(row) {
const pattern = song.patterns[song.currentPattern];
rowClipboard = pattern[row].slice();
}
function pasteRow(row) {
if (!rowClipboard) return;
const pattern = song.patterns[song.currentPattern];
pattern[row] = rowClipboard.slice();
renderPatternEditor();
}
function duplicateRow(row) {
const pattern = song.patterns[song.currentPattern];
const newRow = pattern[row].slice();
// Insert new row below current row
pattern.splice(row, 0, newRow);
// Trim if too many rows
if(pattern.length > ROWS_PER_PATTERN) pattern.pop();
renderPatternEditor();
}
function deleteRow(row) {
const pattern = song.patterns[song.currentPattern];
pattern.splice(row, 1);
// Add empty row at bottom to maintain length
const emptyRow = [];
for(let i = 0; i < song.instruments.length; i++) emptyRow.push(null);
pattern.push(emptyRow);
renderPatternEditor();
}
// Pattern controls
addPatternBtn.addEventListener('click', () => {
song.patterns.push(createEmptyPattern());
song.currentPattern = song.patterns.length -1;
renderPatternEditor();
});
delPatternBtn.addEventListener('click', () => {
if(song.patterns.length <= 1) return alert("Need at least one pattern");
song.patterns.splice(song.currentPattern,1);
song.currentPattern = Math.max(0, song.currentPattern -1);
renderPatternEditor();
});
copyPatternBtn.addEventListener('click', () => {
copiedPattern = JSON.parse(JSON.stringify(song.patterns[song.currentPattern]));
alert("Pattern copied");
});
pastePatternBtn.addEventListener('click', () => {
if(!copiedPattern) return alert("No pattern copied");
song.patterns[song.currentPattern] = JSON.parse(JSON.stringify(copiedPattern));
renderPatternEditor();
});
// Instrument select change
instrumentSelect.addEventListener('change', e => {
currentInstrumentIndex = parseInt(instrumentSelect.value);
loadInstrumentControls();
renderPatternEditor();
});
// Add/Delete Instruments
addInstrumentBtn.addEventListener('click', () => {
if(song.instruments.length >= MAX_INSTRUMENTS) {
alert("Max instruments reached: "+MAX_INSTRUMENTS);
return;
}
const newInst = createDefaultInstrument("Instrument "+(song.instruments.length+1));
song.instruments.push(newInst);
// Add note columns for new instrument in all patterns
song.patterns.forEach(pat => {
pat.forEach(row => row.push(null));
});
currentInstrumentIndex = song.instruments.length -1;
renderInstrumentList();
loadInstrumentControls();
renderPatternEditor();
});
delInstrumentBtn.addEventListener('click', () => {
if(song.instruments.length <= 1) {
alert("Need at least one instrument");
return;
}
song.instruments.splice(currentInstrumentIndex, 1);
// Remove notes for that instrument in all patterns
song.patterns.forEach(pat => {
pat.forEach(row => row.splice(currentInstrumentIndex, 1));
});
currentInstrumentIndex = Math.min(currentInstrumentIndex, song.instruments.length -1);
renderInstrumentList();
loadInstrumentControls();
renderPatternEditor();
});
// Load instrument control values into UI inputs
function loadInstrumentControls() {
const inst = song.instruments[currentInstrumentIndex];
waveformSelect.value = inst.waveform;
volumeRange.value = inst.volume;
detuneRange.value = inst.detune;
attackRange.value = inst.adsr.attack;
decayRange.value = inst.adsr.decay;
sustainRange.value = inst.adsr.sustain;
releaseRange.value = inst.adsr.release;
filterFreqRange.value = inst.filterFreq;
noiseEnableCheckbox.checked = inst.noiseEnable;
}
// Save UI controls to instrument data on input change
waveformSelect.addEventListener('change', e => {
song.instruments[currentInstrumentIndex].waveform = e.target.value;
});
volumeRange.addEventListener('input', e => {
song.instruments[currentInstrumentIndex].volume = parseFloat(e.target.value);
});
detuneRange.addEventListener('input', e => {
song.instruments[currentInstrumentIndex].detune = parseFloat(e.target.value);
});
attackRange.addEventListener('input', e => {
song.instruments[currentInstrumentIndex].adsr.attack = parseFloat(e.target.value);
});
decayRange.addEventListener('input', e => {
song.instruments[currentInstrumentIndex].adsr.decay = parseFloat(e.target.value);
});
sustainRange.addEventListener('input', e => {
song.instruments[currentInstrumentIndex].adsr.sustain = parseFloat(e.target.value);
});
releaseRange.addEventListener('input', e => {
song.instruments[currentInstrumentIndex].adsr.release = parseFloat(e.target.value);
});
filterFreqRange.addEventListener('input', e => {
song.instruments[currentInstrumentIndex].filterFreq = parseFloat(e.target.value);
});
noiseEnableCheckbox.addEventListener('change', e => {
song.instruments[currentInstrumentIndex].noiseEnable = e.target.checked;
});
// BPM input handling
bpmInput.addEventListener('change', e => {
let val = parseInt(e.target.value);
if(isNaN(val) || val < 30) val = 30;
if(val > 400) val = 400;
song.bpm = val;
bpmInput.value = val;
if(isPlaying) stop();
});
// Play and Stop functions - simple playback engine
function play() {
if (isPlaying) return;
if (audioCtx.state === 'suspended') audioCtx.resume();
isPlaying = true;
currentPlayRow = 0;
playIntervalId = setInterval(() => {
playRow(currentPlayRow);
currentPlayRow++;
if (currentPlayRow >= ROWS_PER_PATTERN) currentPlayRow = 0;
}, (60000 / song.bpm) / 4); // 4 rows per beat
playBtn.disabled = true;
stopBtn.disabled = false;
}
function stop() {
if (!isPlaying) return;
clearInterval(playIntervalId);
isPlaying = false;
playBtn.disabled = false;
stopBtn.disabled = true;
}
playBtn.addEventListener('click', play);
stopBtn.addEventListener('click', stop);
// Play one row of the current pattern
function playRow(rowIndex) {
const pattern = song.patterns[song.currentPattern];
for (let instIdx = 0; instIdx < song.instruments.length; instIdx++) {
const note = pattern[rowIndex][instIdx];
if(note) {
playNote(note, song.instruments[instIdx]);
}
}
}
// Play a note (with instrument params) using Web Audio API
function playNote(noteName, instrument) {
const now = audioCtx.currentTime;
const freq = noteToFreq(noteName);
if(freq === 0) return;
if(instrument.noiseEnable){
// Play noise instead of oscillator for this note
const bufferSize = audioCtx.sampleRate * 0.2;
const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const output = noiseBuffer.getChannelData(0);
for(let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const noiseSource = audioCtx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(instrument.filterFreq, now);
const gainNode = audioCtx.createGain();
gainNode.gain.setValueAtTime(instrument.volume, now);
noiseSource.connect(filter).connect(gainNode).connect(audioCtx.destination);
noiseSource.start(now);
// ADSR enveloping gain
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(instrument.volume, now + instrument.adsr.attack);
gainNode.gain.linearRampToValueAtTime(instrument.volume * instrument.adsr.sustain, now + instrument.adsr.attack + instrument.adsr.decay);
gainNode.gain.setTargetAtTime(0, now + instrument.adsr.attack + instrument.adsr.decay + 0.05, instrument.adsr.release);
noiseSource.stop(now + instrument.adsr.attack + instrument.adsr.decay + instrument.adsr.release + 0.1);
return;
}
// Normal oscillator note
const osc = audioCtx.createOscillator();
osc.type = instrument.waveform;
osc.frequency.setValueAtTime(freq, now);
osc.detune.setValueAtTime(instrument.detune, now);
const filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(instrument.filterFreq, now);
const gainNode = audioCtx.createGain();
osc.connect(filter);
filter.connect(gainNode);
gainNode.connect(audioCtx.destination);
// ADSR envelope
const attack = instrument.adsr.attack;
const decay = instrument.adsr.decay;
const sustain = instrument.adsr.sustain;
const release = instrument.adsr.release;
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(instrument.volume, now + attack);
gainNode.gain.linearRampToValueAtTime(instrument.volume * sustain, now + attack + decay);
osc.start(now);
// Note length ~ quarter note duration
const noteDuration = (60 / song.bpm) / 4;
gainNode.gain.setTargetAtTime(0, now + noteDuration, release);
osc.stop(now + noteDuration + release + 0.01);
}
// Share song via URL hash JSON
shareSongBtn.addEventListener('click', () => {
try {
const data = {
bpm: song.bpm,
instruments: song.instruments,
patterns: song.patterns,
currentPattern: song.currentPattern,
};
const str = encodeURIComponent(JSON.stringify(data));
const url = window.location.origin + window.location.pathname + "#song=" + str;
prompt("Copy shareable URL:", url);
} catch (err) {
alert("Failed to encode song for sharing");
}
});
// Load from hash if present
function loadFromHash() {
if(window.location.hash.startsWith("#song=")) {
try {
const jsonStr = decodeURIComponent(window.location.hash.slice(6));
const data = JSON.parse(jsonStr);
if(data.bpm) song.bpm = data.bpm;
if(data.instruments) song.instruments = data.instruments;
if(data.patterns) song.patterns = data.patterns;
if(data.currentPattern != null) song.currentPattern = data.currentPattern;
currentInstrumentIndex = 0;
renderInstrumentList();
loadInstrumentControls();
renderPatternEditor();
bpmInput.value = song.bpm;
} catch(err) {
console.warn("Failed to load song from hash", err);
}
}
}
// Initialize interface
function init() {
renderInstrumentList();
loadInstrumentControls();
renderPatternEditor();
bpmInput.value = song.bpm;
playBtn.disabled = false;
stopBtn.disabled = true;
loadFromHash();
}
init();
})();
</script>
</body>
</html>
```
---
### How to use this chiptune tracker prototype:
- Use arrow keys to navigate pattern grid
- Press **Enter** to toggle note editing at selected cell
- While editing, press A,W,S,E,D,F,T,G,Y,H,U,J keys to input notes (mapped chromatically starting from C) at current octave (default 4)
- Use Up/Down arrows to change octave during note entry
- Press `-` key to clear a note
- Press Ctrl+C to copy a row, Ctrl+V to paste a row, Ctrl+D to duplicate row, Delete to remove row
- Add/delete patterns and instruments via buttons
- Adjust instrument parameters on the right panel - wave, volume, detune, ADSR, filter, noise toggle
- Play/stop playback with buttons
- Share your song by exporting a URL you can save or share
- On page load, if a song is encoded in the URL (hash/fragment), it loads automatically
---
### Limitations and possible improvements:
- Single pattern play only (no pattern order/sequence)
- Simple single note per instrument per row (no chords yet)
- No custom waveform editor, but you can pick 4 basic oscillator types or enable noise for percussion/noise effect
- Volume/detune/adsr/filter/noise implemented per instrument only (not per note)
- Keyboard input mapping is basic, you can extend with full piano roll
- No instrument renaming UI (you can edit code to add this)
- UI could be improved for mobile/touch
- Audio context must be started by user interaction in modern browsers (play button)
---
If you want I can help incrementally add features or optimize parts of this code. Just ask!