TutSynth
Qwen 3.6 35b a3b · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 40,686 bytes · SHA-256 9057758eac7b
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Synth Playground — Learn Synthesis Interactively</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a1a;
color: #e0e0f0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
#app {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
#header {
text-align: center;
padding: 30px 0 20px;
border-bottom: 1px solid #2a2a4a;
margin-bottom: 20px;
}
#header h1 {
font-size: 2.2rem;
background: linear-gradient(135deg, #00ffaa, #00aaff, #aa44ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.subtitle {
color: #8888aa;
font-size: 0.95rem;
}
#main-panel {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 40px;
}
.synth-section {
background: #12122a;
border: 1px solid #2a2a4a;
border-radius: 12px;
overflow: hidden;
transition: border-color 0.3s;
}
.synth-section:hover {
border-color: #4a4a7a;
}
.section-header {
padding: 16px 20px;
background: linear-gradient(90deg, #1a1a3a, #12122a);
border-bottom: 1px solid #2a2a4a;
}
.section-header h2 {
font-size: 1.15rem;
color: #00ffaa;
margin-bottom: 4px;
}
.section-info {
font-size: 0.82rem;
color: #6a6a9a;
font-style: italic;
}
.section-body {
padding: 20px;
}
.control-group {
margin-bottom: 18px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.88rem;
font-weight: 600;
color: #c0c0e0;
margin-bottom: 8px;
}
.value-display {
color: #00ffaa;
font-family: 'Consolas', 'Courier New', monospace;
font-weight: 400;
}
.control-hint {
font-size: 0.78rem;
color: #5a5a8a;
margin-top: 6px;
line-height: 1.4;
padding-left: 4px;
border-left: 2px solid #2a2a4a;
}
/* Buttons */
.waveform-selector,
.filter-selector,
.env-target-selector,
.lfo-wave-selector,
.lfo-target-selector {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.wave-btn,
.filter-btn,
.env-target-btn,
.lfo-wave-btn,
.lfo-target-btn {
padding: 6px 14px;
border: 1px solid #3a3a5a;
border-radius: 6px;
background: #1a1a3a;
color: #8888aa;
font-size: 0.82rem;
cursor: pointer;
transition: all 0.2s;
}
.wave-btn:hover,
.filter-btn:hover,
.env-target-btn:hover,
.lfo-wave-btn:hover,
.lfo-target-btn:hover {
border-color: #00ffaa;
color: #00ffaa;
}
.wave-btn.active,
.filter-btn.active,
.env-target-btn.active,
.lfo-wave-btn.active,
.lfo-target-btn.active {
border-color: #00ffaa;
background: #0a2a2a;
color: #00ffaa;
box-shadow: 0 0 8px rgba(0, 255, 170, 0.2);
}
/* Sliders */
.slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #1a1a3a;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #00ffaa;
cursor: pointer;
box-shadow: 0 0 6px rgba(0, 255, 170, 0.4);
}
.slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #00ffaa;
cursor: pointer;
border: none;
box-shadow: 0 0 6px rgba(0, 255, 170, 0.4);
}
.slider:hover::-webkit-slider-thumb {
box-shadow: 0 0 12px rgba(0, 255, 170, 0.6);
}
/* Envelope sliders */
.env-sliders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.env-param {
margin-bottom: 0;
}
.env-param .control-label {
font-size: 0.82rem;
}
.env-param .slider {
height: 4px;
}
/* Envelope canvas */
.env-canvas-container {
background: #0a0a1a;
border-radius: 8px;
padding: 8px;
margin-bottom: 12px;
display: flex;
justify-content: center;
}
#env-canvas {
border-radius: 4px;
}
/* Keyboard */
.keyboard {
position: relative;
display: flex;
justify-content: center;
margin-bottom: 12px;
user-select: none;
}
.white-keys {
display: flex;
gap: 2px;
}
.white-key {
width: 52px;
height: 160px;
border: 1px solid #3a3a5a;
border-radius: 0 0 6px 6px;
background: linear-gradient(180deg, #e8e8f0, #ffffff);
color: #333;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 10px;
transition: background 0.1s;
}
.white-key:hover {
background: linear-gradient(180deg, #d0d0e8, #f0f0ff);
}
.white-key.active {
background: linear-gradient(180deg, #00ffaa, #00cc88);
border-color: #00ffaa;
box-shadow: 0 0 15px rgba(0, 255, 170, 0.4);
}
.black-keys {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
pointer-events: none;
}
.black-key {
width: 32px;
height: 100px;
border: 1px solid #1a1a2a;
border-radius: 0 0 4px 4px;
background: linear-gradient(180deg, #1a1a2a, #0a0a1a);
color: #888;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 6px;
pointer-events: auto;
transition: background 0.1s;
}
.black-key:hover {
background: linear-gradient(180deg, #2a2a4a, #1a1a3a);
}
.black-key.active {
background: linear-gradient(180deg, #00ffaa, #00cc88);
color: #0a0a1a;
box-shadow: 0 0 15px rgba(0, 255, 170, 0.4);
}
/* Positioning black keys */
.black-key:nth-child(1) { margin-left: 28px; }
.black-key:nth-child(2) { margin-left: 58px; }
.black-key:nth-child(3) { margin-left: 118px; }
.black-key:nth-child(4) { margin-left: 58px; }
.black-key:nth-child(5) { margin-left: 58px; }
.black-key:nth-child(6) { margin-left: 178px; }
.black-key:nth-child(7) { margin-left: 58px; }
/* Active notes */
.active-notes {
text-align: center;
font-size: 0.85rem;
color: #00ffaa;
font-family: 'Consolas', monospace;
min-height: 24px;
}
/* Visualization */
.viz-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.viz-container canvas {
background: #0a0a1a;
border-radius: 8px;
border: 1px solid #2a2a4a;
width: 100%;
}
/* Responsive */
@media (max-width: 600px) {
#app { padding: 12px; }
.white-key { width: 38px; height: 130px; }
.black-key { width: 24px; height: 80px; }
.env-sliders { grid-template-columns: 1fr 1fr; }
}
/* ── css: overlay ── */
.start-overlay {
position: fixed;
inset: 0;
background: rgba(10, 10, 26, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(8px);
transition: opacity 0.5s, visibility 0.5s;
}
.start-overlay.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.start-content {
text-align: center;
max-width: 500px;
padding: 40px;
}
.start-content h1 {
font-size: 2.8rem;
background: linear-gradient(135deg, #00ffaa, #00aaff, #aa44ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 12px;
}
.start-subtitle {
color: #8888aa;
font-size: 1.1rem;
margin-bottom: 24px;
}
.start-instructions {
color: #6a6a9a;
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 30px;
}
.start-btn {
padding: 14px 40px;
font-size: 1.1rem;
font-weight: 600;
border: 2px solid #00ffaa;
border-radius: 8px;
background: transparent;
color: #00ffaa;
cursor: pointer;
transition: all 0.3s;
}
.start-btn:hover {
background: #00ffaa;
color: #0a0a1a;
box-shadow: 0 0 30px rgba(0, 255, 170, 0.4);
}
</style>
</head>
<body>
<header id="header">
<h1>🎛️ Synth Playground</h1>
<p class="subtitle">Learn synthesis by doing — every control is explained</p>
</header>
<div id="main-panel">
<section class="synth-section" id="oscillator-section">
<div class="section-header">
<h2>1. Oscillators — The Sound Source</h2>
<span class="section-info">Oscillators generate the raw waveform. Different shapes = different harmonic content.</span>
</div>
<div class="section-body">
<div class="control-group">
<label class="control-label">Waveform</label>
<div class="waveform-selector">
<button class="wave-btn active" data-wave="sine">Sine</button>
<button class="wave-btn" data-wave="square">Square</button>
<button class="wave-btn" data-wave="sawtooth">Saw</button>
<button class="wave-btn" data-wave="triangle">Triangle</button>
</div>
<p class="control-hint">Sine = pure tone. Square = hollow, hollow. Saw = bright, buzzy. Triangle = soft, flute-like.</p>
</div>
<div class="control-group">
<label class="control-label">Detune <span class="value-display" id="detune-val">0</span> cents</label>
<input type="range" class="slider" id="detune" min="-100" max="100" value="0" step="1">
<p class="control-hint">Slight pitch offset. Detune two oscillators together → chorus / fat unison effect.</p>
</div>
<div class="control-group">
<label class="control-label">Octave <span class="value-display" id="octave-val">0</span></label>
<input type="range" class="slider" id="octave" min="-2" max="2" value="0" step="1">
<p class="control-hint">Shift pitch ±2 octaves. Useful for bass or lead ranges.</p>
</div>
</div>
</section>
<section class="synth-section" id="filter-section">
<div class="section-header">
<h2>2. Filter — Shaping the Tone</h2>
<span class="section-info">Filters remove or emphasize frequencies. Think of it as a "tone knob" you can automate.</span>
</div>
<div class="section-body">
<div class="control-group">
<label class="control-label">Filter Type</label>
<div class="filter-selector">
<button class="filter-btn active" data-filter="lowpass">Lowpass</button>
<button class="filter-btn" data-filter="highpass">Highpass</button>
<button class="filter-btn" data-filter="bandpass">Bandpass</button>
<button class="filter-btn" data-filter="notch">Notch</button>
</div>
<p class="control-hint">Lowpass = keeps lows, removes highs (warm). Highpass = keeps highs, removes lows (thin). Bandpass = narrow band. Notch = removes narrow band.</p>
</div>
<div class="control-group">
<label class="control-label">Cutoff <span class="value-display" id="cutoff-val">2000</span> Hz</label>
<input type="range" class="slider" id="cutoff" min="20" max="20000" value="2000" step="1">
<p class="control-hint">The frequency boundary. For lowpass, everything below passes through. For highpass, everything above.</p>
</div>
<div class="control-group">
<label class="control-label">Resonance (Q) <span class="value-display" id="resonance-val">1</span></label>
<input type="range" class="slider" id="resonance" min="0" max="30" value="1" step="0.1">
<p class="control-hint">Boosts frequencies near cutoff. High Q = pronounced peak at cutoff → classic "squelchy" synth sound.</p>
</div>
</div>
</section>
<section class="synth-section" id="envelope-section">
<div class="section-header">
<h2>3. Envelope (ADSR) — How Sound Evolves Over Time</h2>
<span class="section-info">An envelope controls how a parameter (like volume or filter cutoff) changes from the moment you press a key.</span>
</div>
<div class="section-body">
<div class="control-group">
<label class="control-label">Target</label>
<div class="env-target-selector">
<button class="env-target-btn active" data-target="amplitude">Volume</button>
<button class="env-target-btn" data-target="filter">Filter</button>
</div>
<p class="control-hint">Apply the envelope to volume (classic synth) or filter cutoff (wah-wah sweeps).</p>
</div>
<div class="control-group">
<div id="env-visual" class="env-canvas-container">
<canvas id="env-canvas" width="300" height="120"></canvas>
</div>
</div>
<div class="env-sliders">
<div class="control-group env-param">
<label class="control-label">Attack <span class="value-display" id="attack-val">0.01</span>s</label>
<input type="range" class="slider" id="attack" min="0.001" max="2" value="0.01" step="0.001">
<p class="control-hint">Time from silence to peak. 0 = instant (pluck). Long = pad swell.</p>
</div>
<div class="control-group env-param">
<label class="control-label">Decay <span class="value-display" id="decay-val">0.3</span>s</label>
<input type="range" class="slider" id="decay" min="0.001" max="2" value="0.3" step="0.001">
<p class="control-hint">Time from peak down to sustain level. Short = percussive.</p>
</div>
<div class="control-group env-param">
<label class="control-label">Sustain <span class="value-display" id="sustain-val">0.7</span></label>
<input type="range" class="slider" id="sustain" min="0" max="1" value="0.7" step="0.01">
<p class="control-hint">Level held while key is pressed. 1 = full volume. 0 = cuts to silence.</p>
</div>
<div class="control-group env-param">
<label class="control-label">Release <span class="value-display" id="release-val">0.3</span>s</label>
<input type="range" class="slider" id="release" min="0.001" max="3" value="0.3" step="0.001">
<p class="control-hint">Time from sustain to silence after releasing key. Short = staccato. Long = fade out.</p>
</div>
</div>
</div>
</section>
<section class="synth-section" id="lfo-section">
<div class="section-header">
<h2>4. LFO — Low-Frequency Oscillator (Modulation)</h2>
<span class="section-info">An LFO is a slow oscillator that modulates (changes) other parameters over time, creating movement and expression.</span>
</div>
<div class="section-body">
<div class="control-group">
<label class="control-label">LFO Rate <span class="value-display" id="lfo-rate-val">4</span> Hz</label>
<input type="range" class="slider" id="lfo-rate" min="0.1" max="20" value="4" step="0.1">
<p class="control-hint">Speed of modulation. Slow = vibrato/tremolo. Fast = FM-style effects.</p>
</div>
<div class="control-group">
<label class="control-label">LFO Depth <span class="value-display" id="lfo-depth-val">0</span></label>
<input type="range" class="slider" id="lfo-depth" min="0" max="100" value="0" step="1">
<p class="control-hint">How strongly the LFO affects the target. 0 = no modulation.</p>
</div>
<div class="control-group">
<label class="control-label">LFO Waveform</label>
<div class="lfo-wave-selector">
<button class="lfo-wave-btn active" data-lfo-wave="sine">Sine</button>
<button class="lfo-wave-btn" data-lfo-wave="triangle">Triangle</button>
<button class="lfo-wave-btn" data-lfo-wave="square">Square</button>
<button class="lfo-wave-btn" data-lfo-wave="sawtooth">Saw</button>
</div>
</div>
<div class="control-group">
<label class="control-label">LFO Target</label>
<div class="lfo-target-selector">
<button class="lfo-target-btn active" data-lfo-target="pitch">Pitch</button>
<button class="lfo-target-btn" data-lfo-target="filter">Filter</button>
<button class="lfo-target-btn" data-lfo-target="none">Off</button>
</div>
<p class="control-hint">Pitch → vibrato. Filter → wah-wah sweep. Off → no effect.</p>
</div>
</div>
</section>
<section class="synth-section" id="effects-section">
<div class="section-header">
<h2>5. Effects — Polish the Sound</h2>
<span class="section-info">Effects add spatial depth and character. Try reverb for space, delay for echoes.</span>
</div>
<div class="section-body">
<div class="control-group">
<label class="control-label">Reverb Mix <span class="value-display" id="reverb-mix-val">0</span></label>
<input type="range" class="slider" id="reverb-mix" min="0" max="1" value="0" step="0.01">
<p class="control-hint">Amount of reverb (room ambience). 0 = dry. High = cathedral.</p>
</div>
<div class="control-group">
<label class="control-label">Delay Mix <span class="value-display" id="delay-mix-val">0</span></label>
<input type="range" class="slider" id="delay-mix" min="0" max="1" value="0" step="0.01">
<p class="control-hint">Amount of delay (echo). 0 = no echo. High = dense repeats.</p>
</div>
<div class="control-group">
<label class="control-label">Delay Time <span class="value-display" id="delay-time-val">0.3</span>s</label>
<input type="range" class="slider" id="delay-time" min="0.01" max="1" value="0.3" step="0.01">
<p class="control-hint">Time between repeats. Match to tempo for rhythmic echoes.</p>
</div>
</div>
</section>
<section class="synth-section" id="keyboard-section">
<div class="section-header">
<h2>6. Keyboard — Play!</h2>
<span class="section-info">Use your mouse or keyboard. Keys: Z–M and Q–U map to white/black notes.</span>
</div>
<div class="section-body">
<div id="keyboard" class="keyboard">
<div class="white-keys">
<button class="white-key" data-note="C4" data-key="z">Z</button>
<button class="white-key" data-note="D4" data-key="x">X</button>
<button class="white-key" data-note="E4" data-key="c">C</button>
<button class="white-key" data-note="F4" data-key="v">V</button>
<button class="white-key" data-note="G4" data-key="b">B</button>
<button class="white-key" data-note="A4" data-key="n">N</button>
<button class="white-key" data-note="B4" data-key="m">M</button>
<button class="white-key" data-note="C5" data-key=",">,</button>
<button class="white-key" data-note="D5" data-key=".">.</button>
<button class="white-key" data-note="E5" data-key="/">/</button>
</div>
<div class="black-keys">
<button class="black-key" data-note="C#4" data-key="s">S</button>
<button class="black-key" data-note="D#4" data-key="d">D</button>
<button class="black-key" data-note="F#4" data-key="g">G</button>
<button class="black-key" data-note="G#4" data-key="h">H</button>
<button class="black-key" data-note="A#4" data-key="j">J</button>
<button class="black-key" data-note="C#5" data-key="l">L</button>
<button class="black-key" data-note="D#5" data-key=";">;</button>
</div>
</div>
<div id="active-notes" class="active-notes">No notes playing</div>
</div>
</section>
<section class="synth-section" id="viz-section">
<div class="section-header">
<h2>📊 Live Visualization</h2>
</div>
<div class="section-body">
<div class="viz-container">
<canvas id="waveform-canvas" width="600" height="150"></canvas>
<canvas id="frequency-canvas" width="600" height="150"></canvas>
</div>
<p class="control-hint">Top: waveform shape. Bottom: frequency spectrum. Play notes to see them!</p>
</div>
</section>
<div id="start-overlay" class="start-overlay">
<div class="start-content">
<h1>🎛️ Synth Playground</h1>
<p class="start-subtitle">An interactive guide to synthesis</p>
<p class="start-instructions">
Every control on this page generates real sound.<br>
Adjust knobs, change waveforms, play notes — and watch the visualizations react.
</p>
<button id="start-btn" class="start-btn">Start Playing</button>
</div>
</div></div>
<script>
'use strict';
// ── const: NOTE_FREQUENCIES ──
const NOTE_FREQUENCIES = {
'C4': 261.63, 'C#4': 277.18, 'D4': 293.66, 'D#4': 311.13,
'E4': 329.63, 'F4': 349.23, 'F#4': 369.99, 'G4': 392.00,
'G#4': 415.30, 'A4': 440.00, 'A#4': 466.16, 'B4': 493.88,
'C5': 523.25, 'C#5': 554.37, 'D5': 587.33, 'D#5': 622.25,
'E5': 659.25
};
// ── const: KEY_MAP ──
const KEY_MAP = {
'z': 'C4', 's': 'C#4', 'x': 'D4', 'd': 'D#4', 'c': 'E4',
'v': 'F4', 'g': 'F#4', 'b': 'G4', 'h': 'G#4', 'n': 'A4',
'j': 'A#4', 'm': 'B4', ',': 'C5', 'l': 'C#5', '.': 'D5',
';': 'D#5', '/': 'E5'
};
// ── function: getActiveNoteCount ──
function getActiveNoteCount() {
return synthState.activeVoices.size;
}
// ── function: isNoteActive ──
function isNoteActive(note) {
return synthState.activeVoices.has(note);
}
// ── const: synthState ──
const synthState = {
ctx: null,
masterGain: null,
analyserNode: null,
analyserFreq: null,
// Oscillator
oscType: 'sawtooth',
detune: 0,
octave: 0,
activeVoices: new Map(),
// Filter
filterType: 'lowpass',
filterCutoff: 2000,
filterResonance: 1,
// Envelope
envTarget: 'amplitude',
attack: 0.01,
decay: 0.3,
sustain: 0.7,
release: 0.3,
// LFO
lfoRate: 4,
lfoDepth: 0,
lfoWave: 'sine',
lfoTarget: 'pitch',
lfoNode: null,
lfoGainNode: null,
// Effects
reverbMix: 0,
delayMix: 0,
delayTime: 0.3,
reverbNode: null,
reverbGain: null,
dryGain: null,
delayNode: null,
delayGain: null,
delayFeedback: null,
};
// ── function: createAudioContext ──
function createAudioContext() {
synthState.ctx = new (window.AudioContext || window.webkitAudioContext)();
setupSignalChain();
}
// ── function: setupSignalChain ──
function setupSignalChain() {
const ctx = synthState.ctx;
// Master gain
synthState.masterGain = ctx.createGain();
synthState.masterGain.gain.value = 0.5;
// Analyser for waveform
synthState.analyserNode = ctx.createAnalyser();
synthState.analyserNode.fftSize = 2048;
// Analyser for frequency
synthState.analyserFreq = ctx.createAnalyser();
synthState.analyserFreq.fftSize = 4096;
synthState.analyserFreq.smoothingTimeConstant = 0.8;
// Reverb (convolver with generated impulse)
synthState.reverbNode = ctx.createConvolver();
synthState.reverbGain = ctx.createGain();
synthState.reverbGain.gain.value = 0;
synthState.dryGain = ctx.createGain();
synthState.dryGain.gain.value = 1;
generateReverbImpulse(ctx, synthState.reverbNode, 2.5, 0.3);
// Delay
synthState.delayNode = ctx.createDelay(2);
synthState.delayNode.delayTime.value = 0.3;
synthState.delayGain = ctx.createGain();
synthState.delayGain.gain.value = 0;
synthState.delayFeedback = ctx.createGain();
synthState.delayFeedback.gain.value = 0.35;
// Build the chain: voices -> masterGain -> analyser -> destination
// Also: masterGain -> reverb path -> destination
// Also: masterGain -> delay path -> delayFeedback -> delayNode -> destination
// Dry path
synthState.masterGain.connect(synthState.dryGain);
synthState.dryGain.connect(ctx.destination);
// Reverb path
synthState.masterGain.connect(synthState.reverbNode);
synthState.reverbNode.connect(synthState.reverbGain);
synthState.reverbGain.connect(ctx.destination);
// Delay path
synthState.masterGain.connect(synthState.delayNode);
synthState.delayNode.connect(synthState.delayGain);
synthState.delayGain.connect(ctx.destination);
synthState.delayNode.connect(synthState.delayFeedback);
synthState.delayFeedback.connect(synthState.delayNode);
// Analyser for waveform display
synthState.masterGain.connect(synthState.analyserNode);
// Analyser for frequency display
synthState.masterGain.connect(synthState.analyserFreq);
}
// ── function: generateReverbImpulse ──
function generateReverbImpulse(ctx, convolver, duration, decay) {
const sampleRate = ctx.sampleRate;
const length = sampleRate * duration;
const impulse = ctx.createBuffer(2, length, sampleRate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
}
}
convolver.buffer = impulse;
}
// ── function: noteOn ──
function noteOn(note) {
ensureAudioContext();
const ctx = synthState.ctx;
if (!ctx) return;
if (synthState.activeVoices.has(note)) return; // already playing
const baseFreq = NOTE_FREQUENCIES[note];
if (!baseFreq) return;
const freq = baseFreq * Math.pow(2, synthState.octave);
// Voice oscillator
const osc = ctx.createOscillator();
osc.type = synthState.oscType;
osc.frequency.value = freq;
osc.detune.value = synthState.detune;
// Voice gain (amplitude envelope)
const voiceGain = ctx.createGain();
voiceGain.gain.value = 0;
// Filter
const filterNode = ctx.createBiquadFilter();
filterNode.type = synthState.filterType;
filterNode.frequency.value = synthState.filterCutoff;
filterNode.Q.value = synthState.filterResonance;
// Connect: osc -> filter -> voiceGain -> masterGain
osc.connect(filterNode);
filterNode.connect(voiceGain);
voiceGain.connect(synthState.masterGain);
osc.start();
// Apply amplitude envelope
const now = ctx.currentTime;
voiceGain.gain.cancelScheduledValues(now);
voiceGain.gain.setValueAtTime(0, now);
voiceGain.gain.linearRampToValueAtTime(0.8, now + synthState.attack);
voiceGain.gain.linearRampToValueAtTime(
0.8 * synthState.sustain, now + synthState.attack + synthState.decay
);
// Apply filter envelope if target is filter
if (synthState.envTarget === 'filter') {
const now2 = ctx.currentTime;
const peakCutoff = Math.min(synthState.filterCutoff * 3, 20000);
filterNode.frequency.cancelScheduledValues(now2);
filterNode.frequency.setValueAtTime(synthState.filterCutoff, now2);
filterNode.frequency.linearRampToValueAtTime(peakCutoff, now2 + synthState.attack);
filterNode.frequency.linearRampToValueAtTime(
synthState.filterCutoff * (0.5 + synthState.sustain * 0.5),
now2 + synthState.attack + synthState.decay
);
}
synthState.activeVoices.set(note, { osc, voiceGain, filterNode });
}
// ── function: noteOff ──
function noteOff(note) {
const voice = synthState.activeVoices.get(note);
if (!voice) return;
const { osc, voiceGain, filterNode } = voice;
const ctx = synthState.ctx;
const now = ctx.currentTime;
// Release envelope
voiceGain.gain.cancelScheduledValues(now);
voiceGain.gain.setValueAtTime(voiceGain.gain.value, now);
voiceGain.gain.linearRampToValueAtTime(0, now + synthState.release);
osc.stop(now + synthState.release + 0.1);
// Clean up after release
setTimeout(() => {
try { osc.disconnect(); } catch(e) {}
try { voiceGain.disconnect(); } catch(e) {}
try { filterNode.disconnect(); } catch(e) {}
}, (synthState.release + 0.2) * 1000);
synthState.activeVoices.delete(note);
}
// ── function: updateLFO ──
function updateLFO() {
const ctx = synthState.ctx;
if (!ctx) return;
if (synthState.lfoTarget === 'none') {
if (synthState.lfoNode) {
synthState.lfoNode.stop();
synthState.lfoNode.disconnect();
synthState.lfoNode = null;
}
synthState.lfoGainNode?.disconnect();
synthState.lfoGainNode = null;
return;
}
if (!synthState.lfoNode) {
synthState.lfoNode = ctx.createOscillator();
synthState.lfoNode.type = synthState.lfoWave;
synthState.lfoNode.frequency.value = synthState.lfoRate;
synthState.lfoGainNode = ctx.createGain();
synthState.lfoGainNode.gain.value = synthState.lfoDepth;
synthState.lfoNode.connect(synthState.lfoGainNode);
synthState.lfoNode.start();
}
synthState.lfoNode.frequency.value = synthState.lfoRate;
synthState.lfoNode.type = synthState.lfoWave;
synthState.lfoGainNode.gain.value = synthState.lfoDepth;
// Connect LFO to target
if (synthState.lfoTarget === 'pitch') {
// Connect to all active voice oscillators
for (const [, voice] of synthState.activeVoices) {
try {
synthState.lfoGainNode.disconnect();
synthState.lfoGainNode.connect(voice.osc.detune);
} catch(e) {}
}
} else if (synthState.lfoTarget === 'filter') {
for (const [, voice] of synthState.activeVoices) {
try {
synthState.lfoGainNode.disconnect();
synthState.lfoGainNode.connect(voice.filterNode.frequency);
} catch(e) {}
}
}
}
// ── function: updateFilter ──
function updateFilter() {
for (const [, voice] of synthState.activeVoices) {
voice.filterNode.type = synthState.filterType;
voice.filterNode.frequency.value = synthState.filterCutoff;
voice.filterNode.Q.value = synthState.filterResonance;
}
}
// ── function: updateReverbMix ──
function updateReverbMix() {
if (!synthState.reverbGain) return;
const now = synthState.ctx.currentTime;
synthState.reverbGain.gain.setTargetAtTime(synthState.reverbMix, now, 0.05);
synthState.dryGain.gain.setTargetAtTime(1 - synthState.reverbMix * 0.7, now, 0.05);
}
// ── function: updateDelayMix ──
function updateDelayMix() {
if (!synthState.delayGain) return;
const now = synthState.ctx.currentTime;
synthState.delayGain.gain.setTargetAtTime(synthState.delayMix, now, 0.05);
synthState.delayNode.delayTime.setTargetAtTime(synthState.delayTime, now, 0.05);
}
// ── function: drawWaveform ──
function drawWaveform() {
const canvas = document.getElementById('waveform-canvas');
if (!canvas || !synthState.analyserNode) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const bufferLength = synthState.analyserNode.frequencyBinCount;
const dataArray = new Float32Array(bufferLength);
synthState.analyserNode.getFloatTimeDomainData(dataArray);
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = '#1a1a3a';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
// Waveform
ctx.lineWidth = 2;
ctx.strokeStyle = '#00aaff';
ctx.shadowColor = '#00aaff';
ctx.shadowBlur = 4;
ctx.beginPath();
const sliceWidth = w / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i];
const y = (h / 2) + (v * h / 2) * 0.9;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.stroke();
ctx.shadowBlur = 0;
}
// ── function: drawFrequencySpectrum ──
function drawFrequencySpectrum() {
const canvas = document.getElementById('frequency-canvas');
if (!canvas || !synthState.analyserFreq) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const bufferLength = synthState.analyserFreq.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
synthState.analyserFreq.getByteFrequencyData(dataArray);
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, w, h);
// Draw frequency bars
const barCount = 128;
const barWidth = w / barCount;
const step = Math.floor(bufferLength / barCount);
for (let i = 0; i < barCount; i++) {
const value = dataArray[i * step];
const barHeight = (value / 255) * h * 0.9;
const hue = (i / barCount) * 180;
ctx.fillStyle = `hsl(${hue}, 80%, 60%)`;
ctx.fillRect(i * barWidth, h - barHeight, barWidth - 1, barHeight);
}
}
// ── function: updateActiveNotesDisplay ──
function updateActiveNotesDisplay() {
const el = document.getElementById('active-notes');
if (!el) return;
const notes = Array.from(synthState.activeVoices.keys());
if (notes.length === 0) {
el.textContent = 'No notes playing';
el.style.color = '#00ffaa';
} else {
el.textContent = notes.join(' ');
el.style.color = '#00aaff';
}
}
// ── function: updateValueDisplays ──
function updateValueDisplays() {
const map = {
'detune-val': synthState.detune,
'octave-val': synthState.octave,
'cutoff-val': Math.round(synthState.filterCutoff),
'resonance-val': synthState.filterResonance.toFixed(1),
'attack-val': synthState.attack.toFixed(3),
'decay-val': synthState.decay.toFixed(3),
'sustain-val': synthState.sustain.toFixed(2),
'release-val': synthState.release.toFixed(3),
'lfo-rate-val': synthState.lfoRate.toFixed(1),
'lfo-depth-val': synthState.lfoDepth,
'reverb-mix-val': synthState.reverbMix.toFixed(2),
'delay-mix-val': synthState.delayMix.toFixed(2),
'delay-time-val': synthState.delayTime.toFixed(2),
};
for (const [id, val] of Object.entries(map)) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
}
// ── function: drawEnvelopeGraph ──
function drawEnvelopeGraph() {
const canvas = document.getElementById('env-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Background grid
ctx.strokeStyle = '#1a1a3a';
ctx.lineWidth = 1;
for (let y = 0; y < h; y += 20) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
const totalDuration = synthState.attack + synthState.decay + 0.5 + synthState.release;
const padding = 30;
const graphW = w - padding * 2;
const graphH = h - 20;
// Draw envelope curve
ctx.strokeStyle = '#00ffaa';
ctx.lineWidth = 2;
ctx.shadowColor = '#00ffaa';
ctx.shadowBlur = 6;
ctx.beginPath();
const ax = padding;
const ay = h - 10;
// Attack
const attackX = padding + (synthState.attack / totalDuration) * graphW;
ctx.moveTo(ax, ay);
ctx.lineTo(attackX, 10);
// Decay
const decayEnd = attackX + (synthState.decay / totalDuration) * graphW;
const sustainY = 10 + (1 - synthState.sustain) * graphH;
ctx.lineTo(decayEnd, sustainY);
// Sustain (hold for 0.5s)
const sustainEnd = decayEnd + (0.5 / totalDuration) * graphW;
ctx.lineTo(sustainEnd, sustainY);
// Release
const releaseEnd = sustainEnd + (synthState.release / totalDuration) * graphW;
ctx.lineTo(releaseEnd, ay);
ctx.stroke();
ctx.shadowBlur = 0;
// Labels
ctx.fillStyle = '#6a6a9a';
ctx.font = '11px Consolas, monospace';
ctx.textAlign = 'center';
const labels = [
{ x: (ax + attackX) / 2, label: 'A' },
{ x: (attackX + decayEnd) / 2, label: 'D' },
{ x: (decayEnd + sustainEnd) / 2, label: 'S' },
{ x: (sustainEnd + releaseEnd) / 2, label: 'R' },
];
labels.forEach(l => {
ctx.fillText(l.label, l.x, ay + 16);
});
// Y-axis label
ctx.save();
ctx.translate(8, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Amplitude', 0, 0);
ctx.restore();
// X-axis label
ctx.fillText('Time', w / 2, h - 2);
}
// ── function: ensureAudioContext ──
function ensureAudioContext() {
if (!synthState.ctx) {
synthState.ctx = new (window.AudioContext || window.webkitAudioContext)();
setupSignalChain();
}
if (synthState.ctx.state === 'suspended') {
synthState.ctx.resume();
}
}
// ── main ──
// Make synthState accessible globally for probes
window.synthState = synthState;
// ---- Start overlay ----
const overlay = document.getElementById('start-overlay');
const startBtn = document.getElementById('start-btn');
if (startBtn && overlay) {
startBtn.addEventListener('click', () => {
overlay.classList.add('hidden');
ensureAudioContext();
});
// Also allow any key/click to dismiss
document.addEventListener('keydown', function dismiss(e) {
if (e.key === 'Escape' || e.key === ' ') {
overlay.classList.add('hidden');
ensureAudioContext();
document.removeEventListener('keydown', dismiss);
}
});
}
// ---- Slider bindings ----
const sliderMap = {
'detune': (v) => { synthState.detune = +v; updateLFO(); },
'octave': (v) => { synthState.octave = +v; },
'cutoff': (v) => { synthState.filterCutoff = +v; updateFilter(); },
'resonance': (v) => { synthState.filterResonance = +v; updateFilter(); },
'attack': (v) => { synthState.attack = +v; },
'decay': (v) => { synthState.decay = +v; },
'sustain': (v) => { synthState.sustain = +v; },
'release': (v) => { synthState.release = +v; },
'lfo-rate': (v) => { synthState.lfoRate = +v; updateLFO(); },
'lfo-depth': (v) => { synthState.lfoDepth = +v; updateLFO(); },
'reverb-mix': (v) => { synthState.reverbMix = +v; updateReverbMix(); },
'delay-mix': (v) => { synthState.delayMix = +v; updateDelayMix(); },
'delay-time': (v) => { synthState.delayTime = +v; updateDelayMix(); },
};
for (const [id, handler] of Object.entries(sliderMap)) {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', (e) => {
handler(e.target.value);
updateValueDisplays();
drawEnvelopeGraph();
});
}
}
// ---- Waveform selector ----
document.querySelectorAll('.wave-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
synthState.oscType = btn.dataset.wave;
});
});
// ---- Filter selector ----
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
synthState.filterType = btn.dataset.filter;
updateFilter();
});
});
// ---- Envelope target selector ----
document.querySelectorAll('.env-target-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.env-target-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
synthState.envTarget = btn.dataset.target;
});
});
// ---- LFO waveform selector ----
document.querySelectorAll('.lfo-wave-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.lfo-wave-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
synthState.lfoWave = btn.dataset.lfoWave;
updateLFO();
});
});
// ---- LFO target selector ----
document.querySelectorAll('.lfo-target-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.lfo-target-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
synthState.lfoTarget = btn.dataset.lfoTarget;
updateLFO();
});
});
// ---- Keyboard (mouse) ----
const keyButtons = document.querySelectorAll('.white-key, .black-key');
keyButtons.forEach(btn => {
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
const note = btn.dataset.note;
btn.classList.add('active');
noteOn(note);
updateActiveNotesDisplay();
});
btn.addEventListener('mouseup', () => {
const note = btn.dataset.note;
btn.classList.remove('active');
noteOff(note);
updateActiveNotesDisplay();
});
btn.addEventListener('mouseleave', () => {
const note = btn.dataset.note;
btn.classList.remove('active');
noteOff(note);
updateActiveNotesDisplay();
});
});
// ---- Computer keyboard ----
const pressedKeys = new Set();
document.addEventListener('keydown', (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] && !pressedKeys.has(key)) {
pressedKeys.add(key);
const note = KEY_MAP[key];
noteOn(note);
const btn = document.querySelector(`[data-note="${note}"]`);
if (btn) btn.classList.add('active');
updateActiveNotesDisplay();
}
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key]) {
pressedKeys.delete(key);
const note = KEY_MAP[key];
noteOff(note);
const btn = document.querySelector(`[data-note="${note}"]`);
if (btn) btn.classList.remove('active');
updateActiveNotesDisplay();
}
});
// ---- Main render loop ----
function renderLoop() {
drawWaveform();
drawFrequencySpectrum();
requestAnimationFrame(renderLoop);
}
renderLoop();
// Initial draws
updateValueDisplays();
drawEnvelopeGraph();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"2b54f18f-f036-4bee-b1d5-08f5a2665993","tokensIn":978357,"tokensOut":28355,"tokensTotal":1006712,"turns":33,"toolCalls":40,"failedToolCalls":4,"timestamp":"2026-07-20T23:21:56.257Z"} -->