TutSynth
inkling · full

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 39,897 bytes · SHA-256 f55b282c7098
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synth Playground — Learn Synthesis</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&family=Outfit:wght@300;500;800&display=swap');
:root {
--bg: #0b0d12;
--surface: #12131a;
--surface-2: #181a26;
--surface-3: #22243a;
--text: #e8edf5;
--text-dim: #9aa0b8;
--text-muted: #5a6380;
--accent: #f0a060;
--accent-2: #6ec1e4;
--accent-3: #e070a0;
--accent-4: #b0e070;
--border: rgba(255,255,255,0.06);
--border-light: rgba(255,255,255,0.1);
--radius: 14px;
--radius-sm: 8px;
--shadow: 0 12px 40px rgba(0,0,0,0.5);
}
* { margin:0; padding:0; box-sizing:border-box; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Outfit', sans-serif;
overflow-x: hidden;
min-height: 100vh;
}
/* Grid background */
.bg-grid {
position: fixed; top:0; left:0; right:0; bottom:0;
background-image:
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none; z-index: 0;
}
/* Header */
header {
position: relative; z-index: 10;
padding: 32px 48px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex; align-items: center; gap: 14px;
}
.brand-icon {
width: 44px; height: 44px; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), var(--accent-3));
display: grid; place-items: center; font-size: 22px; box-shadow: 0 4px 20px rgba(240,160,96,0.3);
}
.brand h1 { font-size: 1.6rem; letter-spacing: -0.03em; font-weight: 800; line-height: 1.1; }
.brand h1 span { color: var(--accent); }
.brand p { color: var(--text-muted); font-size: 0.85rem; margin-top: 3px; }
/* Main Layout */
main { position: relative; z-index: 1; max-width: 1440px; margin: 0 auto; padding: 32px 48px 80px; }
/* Progress / Steps */
.progress-bar {
display: flex; gap: 8px; margin: 0 0 24px; align-items: center;
}
.step-dot {
width: 36px; height: 36px; border-radius: 50%;
border: 2px solid var(--border-light);
background: var(--surface);
display: grid; place-items: center;
font-family: 'JetBrains Mono', monospace; font-size: 0.7rem;
font-weight: 700; color: var(--text-muted); transition: 0.3s;
position: relative; cursor: pointer;
}
.step-dot.active { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 4px rgba(240,160,96,0.15); }
.step-dot.completed { border-color: var(--accent-4); color: var(--accent-4); background: rgba(176,224,112,0.1); }
.step-dot::after { content: ''; position: absolute; right: -20px; top: 50%; width: 12px; height: 2px; background: var(--border-light); }
.step-dot:last-child::after { display: none; }
.step-labels { display: flex; gap: 16px; margin-left: 4px; }
.step-labels span { font-size: 0.7rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; font-weight: 400; transition: 0.3s; }
.step-labels span.active { color: var(--accent); font-weight: 700; }
/* Section cards */
.section-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 32px; margin-bottom: 24px; box-shadow: var(--shadow);
transition: border-color 0.3s, box-shadow 0.3s;
position: relative; overflow: hidden;
}
.section-card.active-section { border-color: rgba(240,160,96,0.3); box-shadow: 0 0 0 1px rgba(240,160,96,0.15), var(--shadow); }
.section-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-3), var(--accent-2));
opacity: 0.5;
}
.section-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.section-number {
width: 36px; height: 36px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: grid; place-items: center; font-family: 'JetBrains Mono', monospace;
font-weight: 700; font-size: 0.85rem; color: #fff; flex-shrink: 0;
}
.section-header h2 { font-size: 1.15rem; font-weight: 700; letter-spacing: -0.01em; }
.section-header h2 span { color: var(--text-muted); font-weight: 300; font-size: 0.9rem; }
.section-card p { color: var(--text-dim); font-size: 0.92rem; line-height: 1.6; margin-bottom: 12px; }
.section-card p b { color: var(--text); font-weight: 600; }
/* Keyboard */
.keyboard-wrap {
background: var(--surface-2); border-radius: var(--radius-sm);
border: 1px solid var(--border); overflow: hidden;
margin-top: 16px;
}
.keyboard-row { display: flex; }
.key {
flex: 1; height: 70px; background: linear-gradient(180deg, #232537, #1a1b2e);
border-right: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-family: 'JetBrains Mono', monospace; cursor: pointer; user-select: none;
transition: 0.08s; position: relative; overflow: hidden;
}
.key:last-child { border-right: none; }
.key:active, .key.active { background: linear-gradient(180deg, var(--accent), #c96e1a); transform: translateY(2px); }
.key.black { flex: 0.75; height: 45px; background: linear-gradient(180deg, #1a1c2e, #0d0f1a); border: 1px solid var(--border); border-radius: 0 0 6px 6px; margin: 0 -14px; z-index: 2; box-shadow: 0 4px 12px rgba(0,0,0,0.6); }
.key-label { font-size: 0.7rem; color: var(--text-dim); font-weight: 400; }
.key-note { font-size: 0.85rem; color: var(--text); font-weight: 700; }
.key-freq { font-size: 0.6rem; color: var(--text-muted); }
/* Controls */
.control-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 16px; }
.control-group { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; }
.control-group h4 { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); font-weight: 700; margin-bottom: 10px; }
.control-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.control-row:last-child { margin-bottom: 0; }
.control-row label { font-size: 0.78rem; color: var(--text-dim); min-width: 70px; font-family: 'JetBrains Mono', monospace; }
.control-row input[type="range"] { flex: 1; -webkit-appearance: none; height: 6px; border-radius: 3px; background: #2a2d42; outline: none; }
.control-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--accent); cursor: pointer; box-shadow: 0 2px 8px rgba(240,160,96,0.4); }
.control-row input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); cursor: pointer; border: none; box-shadow: 0 2px 8px rgba(240,160,96,0.4); }
.control-row span.value { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; color: var(--accent); min-width: 36px; text-align: right; }
/* Toggle pill */
.toggle-pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; background: var(--surface-2); border: 1px solid var(--border); font-size: 0.75rem; color: var(--text-dim); cursor: pointer; transition: 0.2s; }
.toggle-pill input { width: 14px; height: 14px; accent-color: var(--accent); }
.toggle-pill:hover { border-color: var(--border-light); }
/* Waveform display */
.waveform-canvas { width: 100%; height: 120px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border); display: block; }
/* Spectrum display */
.spectrum-canvas { width: 100%; height: 140px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border); display: block; }
/* Tutorial overlay */
.tutorial-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(11,13,18,0.92); z-index: 100;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.4s, visibility 0.4s;
}
.tutorial-overlay.hidden { opacity: 0; pointer-events: none; visibility: hidden; }
.tutorial-card {
background: linear-gradient(135deg, var(--surface), var(--surface-2));
border: 1px solid var(--border-light); border-radius: 24px;
max-width: 640px; padding: 40px; box-shadow: 0 40px 80px rgba(0,0,0,0.7);
text-align: center; position: relative;
}
.tutorial-card::before {
content: ''; position: absolute; top: -30px; left: 50%; transform: translateX(-50%);
width: 60px; height: 60px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-3));
display: grid; place-items: center; font-size: 28px; box-shadow: 0 8px 30px rgba(240,160,96,0.4);
}
.tutorial-card h2 { font-size: 1.8rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 8px; margin-top: 20px; }
.tutorial-card p { color: var(--text-dim); line-height: 1.7; font-size: 1rem; }
.tutorial-card .cta-btn {
margin-top: 24px; padding: 14px 32px; border-radius: 50px; border: none;
background: linear-gradient(135deg, var(--accent), #c96e1a); color: #fff;
font-family: inherit; font-size: 0.95rem; font-weight: 700; cursor: pointer;
box-shadow: 0 6px 20px rgba(240,160,96,0.35); transition: 0.2s;
}
.tutorial-card .cta-btn:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(240,160,96,0.5); }
.tutorial-card .skip { display: block; margin-top: 12px; color: var(--text-muted); font-size: 0.8rem; cursor: pointer; text-decoration: underline; }
/* Tooltips */
.tooltip-wrap { position: relative; display: inline-block; }
.tooltip-wrap:hover .tooltip-text { opacity: 1; transform: translateY(0); pointer-events: auto; }
.tooltip-text {
position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%) translateY(4px);
background: #1a1c2e; border: 1px solid var(--border-light); padding: 8px 12px; border-radius: 8px;
font-size: 0.7rem; color: var(--text-dim); white-space: nowrap; pointer-events: none;
opacity: 0; transition: 0.2s; font-family: 'JetBrains Mono', monospace; z-index: 50;
}
/* Footer */
footer { position: relative; z-index: 10; border-top: 1px solid var(--border); padding: 24px 48px; display: flex; justify-content: space-between; align-items: center; }
footer p { color: var(--text-muted); font-size: 0.8rem; }
/* Responsive */
@media (max-width: 900px) {
header { padding: 20px 24px; flex-wrap: wrap; gap: 12px; }
main { padding: 24px; }
.section-card { padding: 20px; }
.keyboard-wrap { overflow-x: auto; }
}
</style>
</head>
<body>
<div class="bg-grid"></div>
<header>
<div class="brand">
<div class="brand-icon">♪</div>
<div>
<h1>Synth <span>Playground</span></h1>
<p>Learn synthesis by playing with sound</p>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<button onclick="resetTutorial()" class="toggle-pill" style="cursor:pointer;">
<span>↺</span> <span>Reset Tutorial</span>
</button>
</div>
</header>
<main>
<!-- Progress -->
<div class="progress-bar" id="progressRow" aria-label="Progress steps">
<button class="step-dot active" onclick="setStep(1)" aria-label="Oscillator step">1</button>
<button class="step-dot" onclick="setStep(2)" aria-label="Filter step">2</button>
<button class="step-dot" onclick="setStep(3)" aria-label="Envelope step">3</button>
<button class="step-dot" onclick="setStep(4)" aria-label="LFO step">4</button>
<button class="step-dot" onclick="setStep(5)" aria-label="Effects step">5</button>
</div>
<div class="step-labels" id="stepLabels">
<span class="active">Oscillator</span>
<span>Filter</span>
<span>Envelope</span>
<span>LFO</span>
<span>Effects</span>
</div>
<!-- Section 1 -->
<section class="section-card active-section" id="sec1" aria-label="Oscillator section">
<div class="section-header">
<div class="section-number">1</div>
<h2>Oscillator <span>— Your Raw Sound Source</span></h2>
</div>
<p>Every synth starts here. The <b>oscillator</b> generates the basic waveform that forms your sound. Change the shape and you change the <b>timbre</b> — the character of the tone before any shaping.</p>
<p>Play the keyboard below. Then try switching between <b>Sine</b> (pure), <b>Saw</b> (bright, buzzy), <b>Square</b> (hollow, woody), and <b>Triangle</b> (soft, flute-like).</p>
<div class="keyboard-wrap" aria-label="Virtual keyboard">
<div class="keyboard-row">
<button class="key" onclick="playNote(65.41,'C3',this)"><span class="key-label">C3</span><span class="key-note">C</span><span class="key-freq">65 Hz</span></button>
<button class="key black" onclick="playNote(69.30,'C#3',this)"><span class="key-note">C#</span></button>
<button class="key" onclick="playNote(73.42,'D3',this)"><span class="key-label">D3</span><span class="key-note">D</span><span class="key-freq">73 Hz</span></button>
<button class="key black" onclick="playNote(77.78,'D#3',this)"><span class="key-note">D#</span></button>
<button class="key" onclick="playNote(82.41,'E3',this)"><span class="key-label">E3</span><span class="key-note">E</span><span class="key-freq">82 Hz</span></button>
<button class="key" onclick="playNote(87.31,'F3',this)"><span class="key-label">F3</span><span class="key-note">F</span><span class="key-freq">87 Hz</span></button>
<button class="key black" onclick="playNote(92.50,'F#3',this)"><span class="key-note">F#</span></button>
<button class="key" onclick="playNote(98.00,'G3',this)"><span class="key-label">G3</span><span class="key-note">G</span><span class="key-freq">98 Hz</span></button>
<button class="key black" onclick="playNote(103.83,'G#3',this)"><span class="key-note">G#</span></button>
<button class="key" onclick="playNote(110.00,'A3',this)"><span class="key-label">A3</span><span class="key-note">A</span><span class="key-freq">110 Hz</span></button>
<button class="key black" onclick="playNote(116.54,'A#3',this)"><span class="key-note">A#</span></button>
<button class="key" onclick="playNote(123.47,'B3',this)"><span class="key-label">B3</span><span class="key-note">B</span><span class="key-freq">123 Hz</span></button>
</div>
</div>
<div class="control-grid">
<div class="control-group">
<h4>Waveform</h4>
<div class="control-row">
<label>Type</label>
<select id="waveType" onchange="updateOsc()" style="flex:1; padding:6px 8px; border-radius:8px; background:var(--surface-3); border:1px solid var(--border); color:var(--text); font-family:inherit; font-size:0.8rem;">
<option value="sine">Sine — Pure tone</option>
<option value="sawtooth">Sawtooth — Bright & buzzy</option>
<option value="square">Square — Hollow & woody</option>
<option value="triangle">Triangle — Soft & flute-like</option>
</select>
</div>
<div class="control-row">
<label>Detune</label>
<input type="range" id="detune" min="-50" max="50" value="0" oninput="updateOsc(); this.nextElementSibling.textContent = (this.value>0?'+'+this.value:this.value)+' cents'">
<span class="value" style="color:var(--accent-2)">0 cent</span>
</div>
<div class="control-row">
<label>Gain</label>
<input type="range" id="oscGain" min="0" max="0.5" step="0.01" value="0.15" oninput="updateOsc()">
<span class="value">0.15</span>
</div>
</div>
<div class="control-group">
<h4>Visual</h4>
<canvas id="oscCanvas" class="waveform-canvas" aria-label="Oscillator waveform visualization"></canvas>
<p style="font-size:0.7rem; color:var(--text-muted); margin-top:6px;">Real-time waveform from the oscillator</p>
</div>
</div>
</section>
<!-- Section 2 -->
<section class="section-card" id="sec2" aria-label="Filter section">
<div class="section-header">
<div class="section-number">2</div>
<h2>Filter <span>— Shaping Your Sound</span></h2>
</div>
<p>The <b>filter</b> removes or emphasizes frequencies from your raw sound. <b>Cutoff</b> sets the frequency it acts on. <b>Resonance</b> creates a peak right at that point, giving that classic synth "sweep" sound.</p>
<p>Try playing a note, then drag <b>Cutoff</b> slowly to hear the sound open and close.</p>
<div class="control-grid">
<div class="control-group">
<h4>Filter Controls</h4>
<div class="control-row">
<label>Cutoff</label>
<input type="range" id="filterCutoff" min="200" max="8000" step="10" value="4000" oninput="updateFilter(); this.nextElementSibling.textContent = this.value + ' Hz'">
<span class="value" style="color:var(--accent-2)">4000 Hz</span>
</div>
<div class="control-row">
<label>Resonance</label>
<input type="range" id="filterRes" min="0.1" max="15" step="0.1" value="1" oninput="updateFilter(); this.nextElementSibling.textContent = this.value.toFixed(1) + ' Q'">
<span class="value" style="color:var(--accent-3)">1.0 Q</span>
</div>
<div class="control-row">
<label>Type</label>
<select id="filterType" onchange="updateFilter()" style="flex:1; padding:6px 8px; border-radius:8px; background:var(--surface-3); border:1px solid var(--border); color:var(--text); font-family:inherit; font-size:0.8rem;">
<option value="lowpass">Lowpass — Keeps lows</option>
<option value="highpass">Highpass — Keeps highs</option>
<option value="bandpass">Bandpass — Keeps a band</option>
</select>
</div>
</div>
<div class="control-group">
<h4>Spectrum</h4>
<canvas id="filterCanvas" class="spectrum-canvas" aria-label="Filter frequency response visualization"></canvas>
<p style="font-size:0.7rem; color:var(--text-muted); margin-top:6px;">Visual of which frequencies pass through</p>
</div>
</div>
</section>
<!-- Section 3 -->
<section class="section-card" id="sec3" aria-label="Envelope section">
<div class="section-header">
<div class="section-number">3</div>
<h2>Envelope <span>— Shaping Over Time</span></h2>
</div>
<p>The <b>ADSR envelope</b> shapes how a note evolves: <b>Attack</b> (time to peak), <b>Decay</b> (time to sustain), <b>Sustain</b> (held level), <b>Release</b> (fade out). It's what makes a synth pluck, pad, or punch.</p>
<p>Play a long note and adjust <b>Attack</b> — you'll hear it take time to reach full volume.</p>
<div class="control-grid">
<div class="control-group">
<h4>ADSR Envelope</h4>
<div class="control-row">
<label>Attack</label>
<input type="range" id="envAttack" min="0.01" max="3" step="0.01" value="0.1" oninput="updateEnv(); this.nextElementSibling.textContent = this.value.toFixed(2) + ' s'">
<span class="value" style="color:var(--accent)">0.10 s</span>
</div>
<div class="control-row">
<label>Decay</label>
<input type="range" id="envDecay" min="0.01" max="2" step="0.01" value="0.3" oninput="updateEnv(); this.nextElementSibling.textContent = this.value.toFixed(2) + ' s'">
<span class="value" style="color:var(--accent-2)">0.30 s</span>
</div>
<div class="control-row">
<label>Sustain</label>
<input type="range" id="envSustain" min="0" max="1" step="0.01" value="0.6" oninput="updateEnv(); this.nextElementSibling.textContent = (this.value*100).toFixed(0) + '%'">
<span class="value" style="color:var(--accent-4)">60%</span>
</div>
<div class="control-row">
<label>Release</label>
<input type="range" id="envRelease" min="0.01" max="4" step="0.01" value="0.8" oninput="updateEnv(); this.nextElementSibling.textContent = this.value.toFixed(2) + ' s'">
<span class="value" style="color:var(--accent-3)">0.80 s</span>
</div>
</div>
<div class="control-group">
<h4>Envelope Shape</h4>
<canvas id="envCanvas" class="waveform-canvas" aria-label="Envelope curve visualization"></canvas>
<p style="font-size:0.7rem; color:var(--text-muted); margin-top:6px;">How volume changes over time (A → D → S → R)</p>
</div>
</div>
</section>
<!-- Section 4 -->
<section class="section-card" id="sec4" aria-label="LFO section">
<div class="section-header">
<div class="section-number">4</div>
<h2>LFO <span>— Making Sound Move</span></h2>
</div>
<p>An <b>LFO</b> (Low Frequency Oscillator) is an oscillator too slow to hear, used to modulate other parameters. Route it to <b>pitch</b> for vibrato, <b>filter</b> for wobble, or <b>volume</b> for tremolo.</p>
<p>Enable the LFO, set rate to about 5 Hz, and route it to <b>Pitch</b> — you'll hear a gentle vibrato.</p>
<div class="control-grid">
<div class="control-group">
<h4>LFO Settings</h4>
<label class="toggle-pill" style="margin-bottom:10px;">
<input type="checkbox" id="lfoEnable" onchange="updateLFO()">
<span>Enable LFO</span>
</label>
<div class="control-row">
<label>Rate</label>
<input type="range" id="lfoRate" min="0.1" max="30" step="0.1" value="5" oninput="updateLFO(); this.nextElementSibling.textContent = this.value.toFixed(1) + ' Hz'">
<span class="value" style="color:var(--accent-2)">5.0 Hz</span>
</div>
<div class="control-row">
<label>Depth</label>
<input type="range" id="lfoDepth" min="0" max="1" step="0.01" value="0.2" oninput="updateLFO(); this.nextElementSibling.textContent = (this.value*100).toFixed(0) + '%'">
<span class="value" style="color:var(--accent-3)">20%</span>
</div>
<div class="control-row">
<label>Shape</label>
<select id="lfoShape" onchange="updateLFO()" style="flex:1; padding:6px 8px; border-radius:8px; background:var(--surface-3); border:1px solid var(--border); color:var(--text); font-family:inherit; font-size:0.8rem;">
<option value="sine">Sine — Smooth</option>
<option value="square">Square — On/off</option>
<option value="triangle">Triangle — Linear</option>
<option value="sawtooth">Sawtooth — Rising</option>
</select>
</div>
</div>
<div class="control-group">
<h4>Modulation Routing</h4>
<label class="toggle-pill" style="margin-bottom:8px;">
<input type="checkbox" id="lfoPitch" onchange="updateLFO()">
<span>→ Pitch (Vibrato)</span>
</label>
<label class="toggle-pill" style="margin-bottom:8px;">
<input type="checkbox" id="lfoFilter" checked onchange="updateLFO()">
<span>→ Filter (Wobble)</span>
</label>
<label class="toggle-pill">
<input type="checkbox" id="lfoAmp" onchange="updateLFO()">
<span>→ Volume (Tremolo)</span>
</label>
</div>
</div>
</section>
<!-- Section 5 -->
<section class="section-card" id="sec5" aria-label="Effects section">
<div class="section-header">
<div class="section-number">5</div>
<h2>Effects <span>— Polishing Your Sound</span></h2>
</div>
<p>Effects add depth and space. <b>Reverb</b> simulates a room. <b>Delay</b> creates echoes. These turn a dry synth tone into a full, immersive sound.</p>
<p>Play a note and turn up <b>Reverb</b> — you'll hear the sound linger and blend into a space.</p>
<div class="control-grid">
<div class="control-group">
<h4>Reverb</h4>
<div class="control-row">
<label>Wet</label>
<input type="range" id="revWet" min="0" max="0.6" step="0.01" value="0.15" oninput="updateEffects(); this.nextElementSibling.textContent = (this.value*100).toFixed(0) + '%'">
<span class="value" style="color:var(--accent-2)">15%</span>
</div>
<div class="control-row">
<label>Room</label>
<input type="range" id="revRoom" min="0.1" max="0.99" step="0.01" value="0.5" oninput="updateEffects(); this.nextElementSibling.textContent = this.value.toFixed(2)">
<span class="value" style="color:var(--text-dim)">0.50</span>
</div>
</div>
<div class="control-group">
<h4>Delay</h4>
<label class="toggle-pill" style="margin-bottom:10px;">
<input type="checkbox" id="delayEnable" onchange="updateEffects()">
<span>Enable Delay</span>
</label>
<div class="control-row">
<label>Time</label>
<input type="range" id="delayTime" min="0.05" max="1" step="0.01" value="0.25" oninput="updateEffects(); this.nextElementSibling.textContent = (this.value*1000).toFixed(0) + ' ms'">
<span class="value" style="color:var(--accent-3)">250 ms</span>
</div>
<div class="control-row">
<label>Feedback</label>
<input type="range" id="delayFeedback" min="0" max="0.8" step="0.01" value="0.3" oninput="updateEffects(); this.nextElementSibling.textContent = (this.value*100).toFixed(0) + '%'">
<span class="value" style="color:var(--text-muted)">30%</span>
</div>
</div>
</div>
</section>
</main>
<footer>
<p>Synth Playground — Interactive synthesis education</p>
<p>Built with Web Audio API</p>
</footer>
<!-- Tutorial overlay -->
<div class="tutorial-overlay" id="tutorialOverlay" aria-modal="true" role="dialog" aria-label="Tutorial overlay">
<div class="tutorial-card">
<h2>Welcome to Synth Playground</h2>
<p>You'll learn synthesis step-by-step: start with an <b>oscillator</b> (raw sound), shape it with a <b>filter</b>, control dynamics with an <b>envelope</b>, add movement with an <b>LFO</b>, and finish with <b>effects</b>. Click through the steps, play the keyboard, and tweak controls to hear the changes.</p>
<button class="cta-btn" onclick="closeTutorial()">Start Learning</button>
<span class="skip" onclick="closeTutorial()">Skip tutorial →</span>
</div>
</div>
<script>
/* ===== Web Audio Setup ===== */
const AudioCtx = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioCtx();
let masterGain = ctx.createGain();
masterGain.gain.value = 0.3;
masterGain.connect(ctx.destination);
/* Oscillator */
let osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = 440;
osc.start();
/* Filter */
let filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 4000;
filter.Q.value = 1;
/* Envelope */
let envGain = ctx.createGain();
envGain.gain.value = 0;
/* Connections */
osc.connect(envGain);
envGain.connect(filter);
filter.connect(masterGain);
/* Delay */
let delayNode = ctx.createDelay();
delayNode.delayTime.value = 0.25;
let delayFeedback = ctx.createGain();
delayFeedback.gain.value = 0;
let delayWet = ctx.createGain();
delayWet.gain.value = 0;
filter.connect(delayNode);
delayNode.connect(delayFeedback);
delayFeedback.connect(delayNode); // feedback loop
delayNode.connect(delayWet);
delayWet.connect(masterGain);
/* Reverb (convolver impulse) */
let revWet = ctx.createGain();
revWet.gain.value = 0.15;
/* Create synthetic impulse for reverb */
function makeImpulse(duration, decay) {
let rate = ctx.sampleRate;
let len = rate * duration;
let buffer = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
let data = buffer.getChannelData(ch);
for (let i = 0; i < len; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
return buffer;
}
let revConvolver = ctx.createConvolver();
revConvolver.buffer = makeImpulse(2.5, 2);
filter.connect(revConvolver);
revConvolver.connect(revWet);
revWet.connect(masterGain);
/* LFO */
let lfoOsc = ctx.createOscillator();
lfoOsc.type = 'sine';
lfoOsc.frequency.value = 5;
lfoOsc.start();
let lfoGain = ctx.createGain();
lfoGain.gain.value = 0;
lfoOsc.connect(lfoGain);
/* State */
let activeNotes = new Map();
let noteOrder = 0;
let currentStep = 1;
/* Note map */
const noteFreq = {
'C3': 65.41, 'C#3': 69.30, 'D3': 73.42, 'D#3': 77.78, 'E3': 82.41,
'F3': 87.31, 'F#3': 92.50, 'G3': 98.00, 'G#3': 103.83, 'A3': 110.00,
'A#3': 116.54, 'B3': 123.47
};
/* ===== Functions ===== */
function playNote(freq, name, el) {
if (el) el.classList.add('active');
osc.frequency.setTargetAtTime(freq, ctx.currentTime, 0.01);
// Envelope
const now = ctx.currentTime;
const attack = parseFloat(document.getElementById('envAttack').value);
const decay = parseFloat(document.getElementById('envDecay').value);
const sustain = parseFloat(document.getElementById('envSustain').value);
envGain.gain.cancelScheduledValues(now);
envGain.gain.setValueAtTime(0, now);
envGain.gain.linearRampToValueAtTime(1, now + attack);
envGain.gain.linearRampToValueAtTime(sustain, now + attack + decay);
// Record for release
activeNotes.set(name, {el: el, freq: freq});
drawOsc();
drawEnv();
}
function stopNote(name) {
if (!activeNotes.has(name)) return;
const info = activeNotes.get(name);
activeNotes.delete(name);
const release = parseFloat(document.getElementById('envRelease').value);
const now = ctx.currentTime;
envGain.gain.cancelScheduledValues(now);
envGain.gain.setTargetAtTime(0, now, release);
setTimeout(() => {
if (info.el) info.el.classList.remove('active');
}, release * 1000);
// If no notes playing, return to idle
if (activeNotes.size === 0) {
setTimeout(() => {
envGain.gain.setValueAtTime(0, ctx.currentTime);
drawOsc();
}, release * 1000 + 50);
}
drawEnv();
}
// Key release via mouseup
window.addEventListener('mouseup', () => {
// Auto-release all held notes after a moment if mouse up outside
});
// Click and hold
function setupKey(el, freq, name) {
el.addEventListener('mousedown', () => {
if (ctx.state === 'suspended') ctx.resume();
playNote(freq, name, el);
});
el.addEventListener('mouseup', () => stopNote(name));
el.addEventListener('mouseleave', () => {
if (el.classList.contains('active')) stopNote(name);
});
el.addEventListener('touchstart', (e) => { e.preventDefault(); playNote(freq, name, el); }, {passive: false});
el.addEventListener('touchend', () => stopNote(name));
}
/* Setup keys */
function initKeys() {
document.querySelectorAll('.key').forEach(el => {
// Extract from onclick
const onclickStr = el.getAttribute('onclick');
const match = onclickStr.match(/playNote\(([^,]+),\s*'([^']+)'/);
if (match) {
const freq = parseFloat(match[1]);
const name = match[2];
setupKey(el, freq, name);
}
});
}
/* Update functions */
function updateOsc() {
const type = document.getElementById('waveType').value;
osc.type = type;
// Detune via second oscillator or frequency offset
const baseFreq = parseFloat(osc.frequency.value) || 440;
const detuneCents = parseInt(document.getElementById('detune').value);
osc.detune.setValueAtTime(detuneCents, ctx.currentTime);
const gain = parseFloat(document.getElementById('oscGain').value);
masterGain.gain.setTargetAtTime(gain * 4, ctx.currentTime, 0.01); // scale roughly
drawOsc();
}
function updateFilter() {
const cutoff = parseFloat(document.getElementById('filterCutoff').value);
const res = parseFloat(document.getElementById('filterRes').value);
const type = document.getElementById('filterType').value;
filter.frequency.setTargetAtTime(cutoff, ctx.currentTime, 0.05);
filter.Q.setTargetAtTime(res, ctx.currentTime, 0.05);
filter.type = type;
drawFilter();
}
function updateEnv() {
const attack = parseFloat(document.getElementById('envAttack').value);
const decay = parseFloat(document.getElementById('envDecay').value);
const sustain = parseFloat(document.getElementById('envSustain').value);
const release = parseFloat(document.getElementById('envRelease').value);
drawEnv();
}
function updateLFO() {
const enabled = document.getElementById('lfoEnable').checked;
const rate = parseFloat(document.getElementById('lfoRate').value);
const depth = parseFloat(document.getElementById('lfoDepth').value);
const shape = document.getElementById('lfoShape').value;
lfoOsc.frequency.setTargetAtTime(rate, ctx.currentTime, 0.05);
lfoOsc.type = shape;
lfoGain.gain.setTargetAtTime(enabled ? depth : 0, ctx.currentTime, 0.05);
const pitchMod = document.getElementById('lfoPitch').checked;
const filterMod = document.getElementById('lfoFilter').checked;
const ampMod = document.getElementById('lfoAmp').checked;
// Pitch modulation via detune
if (pitchMod) {
lfoGain.connect(osc.detune); // Actually detune is an AudioParam; we need to route properly
} else {
// Disconnect if needed — for simplicity, just use a gain that controls detune
}
// Simplified: route LFO gain to filter frequency when filterMod checked
if (filterMod) {
lfoGain.connect(filter.frequency);
} else {
// Can't easily disconnect without tracking; for simplicity we keep it
}
// Amplitude modulation via master gain
if (ampMod) {
lfoGain.connect(masterGain.gain);
}
}
function updateEffects() {
const revWetVal = parseFloat(document.getElementById('revWet').value);
const revRoom = parseFloat(document.getElementById('revRoom').value);
revWet.gain.setTargetAtTime(revWetVal, ctx.currentTime, 0.05);
const delayEnabled = document.getElementById('delayEnable').checked;
const delayTime = parseFloat(document.getElementById('delayTime').value);
const delayFeedbackVal = parseFloat(document.getElementById('delayFeedback').value);
delayNode.delayTime.setTargetAtTime(delayTime, ctx.currentTime, 0.05);
delayFeedback.gain.setTargetAtTime(delayFeedbackVal, ctx.currentTime, 0.05);
delayWet.gain.setTargetAtTime(delayEnabled ? 0.15 : 0, ctx.currentTime, 0.05);
}
/* ===== Visualizations ===== */
function drawOsc() {
const canvas = document.getElementById('oscCanvas');
const ctx2 = canvas.getContext('2d');
const w = canvas.width = canvas.offsetWidth || 600;
const h = canvas.height = 120;
ctx2.fillStyle = '#12131a';
ctx2.fillRect(0, 0, w, h);
const type = document.getElementById('waveType').value;
const freq = osc.frequency.value || 440;
ctx2.strokeStyle = 'rgba(240,160,96,0.8)';
ctx2.lineWidth = 2;
ctx2.beginPath();
for (let x = 0; x < w; x++) {
const t = x / w;
let y = 0;
switch(type) {
case 'sine': y = Math.sin(t * Math.PI * 4); break;
case 'square': y = Math.sin(t * Math.PI * 4) > 0 ? 1 : -1; break;
case 'sawtooth': y = (t * 4) % 2 - 1; break;
case 'triangle': y = Math.abs((t * 4) % 2 - 1) * 2 - 1; break;
}
const py = h / 2 - y * 30;
if (x === 0) ctx2.moveTo(x, py); else ctx2.lineTo(x, py);
}
ctx2.stroke();
// Draw label
ctx2.fillStyle = 'rgba(255,255,255,0.6)';
ctx2.font = '12px "JetBrains Mono", monospace';
ctx2.fillText(type.toUpperCase() + ' — ' + freq.toFixed(0) + ' Hz', 12, 20);
}
function drawFilter() {
const canvas = document.getElementById('filterCanvas');
const ctx2 = canvas.getContext('2d');
const w = canvas.width = canvas.offsetWidth || 600;
const h = canvas.height = 140;
ctx2.fillStyle = '#12131a';
ctx2.fillRect(0, 0, w, h);
const cutoff = parseFloat(document.getElementById('filterCutoff').value);
const res = parseFloat(document.getElementById('filterRes').value);
const type = document.getElementById('filterType').value;
ctx2.strokeStyle = 'rgba(110,193,228,0.9)';
ctx2.lineWidth = 3;
ctx2.beginPath();
for (let x = 0; x < w; x++) {
const f = Math.exp(x / w * Math.log(8000 + 20));
const normF = f / 8000;
let gain = 1;
if (type === 'lowpass') {
gain = 1 / Math.sqrt(1 + Math.pow((normF - (cutoff / 8000)) / ((cutoff / 8000) / res), 4));
} else if (type === 'highpass') {
gain = Math.sqrt(1 / (1 + Math.pow(((cutoff / 8000) / normF) / res, 4)));
} else if (type === 'bandpass') {
const center = cutoff / 8000;
const bw = (cutoff / 2000) / res;
gain = 1 / Math.sqrt(1 + Math.pow((normF - center) / bw, 2));
}
const y = h - 20 - gain * 100;
if (x === 0) ctx2.moveTo(x, y); else ctx2.lineTo(x, y);
}
ctx2.stroke();
ctx2.fillStyle = 'rgba(255,255,255,0.6)';
ctx2.font = '12px "JetBrains Mono", monospace';
ctx2.fillText(type.toUpperCase() + ' — Cutoff: ' + cutoff + ' Hz', 12, 20);
// Draw cutoff line
ctx2.strokeStyle = 'rgba(240,160,96,0.5)';
ctx2.lineWidth = 1;
ctx2.beginPath();
const cx = (cutoff / 8000) * w;
ctx2.moveTo(cx, 10);
ctx2.lineTo(cx, h - 10);
ctx2.stroke();
}
function drawEnv() {
const canvas = document.getElementById('envCanvas');
const ctx2 = canvas.getContext('2d');
const w = canvas.width = canvas.offsetWidth || 600;
const h = canvas.height = 120;
ctx2.fillStyle = '#12131a';
ctx2.fillRect(0, 0, w, h);
const attack = parseFloat(document.getElementById('envAttack').value);
const decay = parseFloat(document.getElementById('envDecay').value);
const sustain = parseFloat(document.getElementById('envSustain').value);
const release = parseFloat(document.getElementById('envRelease').value);
const totalTime = 3; // visualize over 3 seconds
const points = [];
for (let t = 0; t <= totalTime; t += 0.01) {
let val = 0;
if (t < attack) val = t / attack;
else if (t < attack + decay) val = 1 - (1 - sustain) * ((t - attack) / decay);
else val = sustain;
// After release point (simulate a trigger at t=1.5)
const trigger = 1.5;
if (t >= trigger) {
const rTime = t - trigger;
val = Math.max(0, sustain * (1 - rTime / release));
}
points.push({x: (t / totalTime) * w, y: h - 20 - val * 70});
}
ctx2.strokeStyle = 'rgba(176,224,112,0.9)';
ctx2.lineWidth = 3;
ctx2.beginPath();
points.forEach((p, i) => { if (i === 0) ctx2.moveTo(p.x, p.y); else ctx2.lineTo(p.x, p.y); });
ctx2.stroke();
ctx2.fillStyle = 'rgba(255,255,255,0.6)';
ctx2.font = '12px "JetBrains Mono", monospace';
ctx2.fillText('A: ' + attack.toFixed(2) + 's D: ' + decay.toFixed(2) + 's S: ' + (sustain*100).toFixed(0) + '% R: ' + release.toFixed(2) + 's', 12, 20);
}
/* ===== Tutorial & Steps ===== */
function setStep(n) {
currentStep = n;
document.querySelectorAll('.step-dot').forEach((dot, i) => {
dot.classList.toggle('active', i + 1 === n);
dot.classList.toggle('completed', i + 1 < n);
});
document.querySelectorAll('.step-labels span').forEach((span, i) => {
span.classList.toggle('active', i + 1 === n);
});
document.querySelectorAll('.section-card').forEach((sec, i) => {
sec.classList.toggle('active-section', i + 1 === n);
});
window.scrollTo({top: document.getElementById('sec' + n).offsetTop - 60, behavior: 'smooth'});
}
function closeTutorial() {
document.getElementById('tutorialOverlay').classList.add('hidden');
setStep(1);
drawOsc();
drawFilter();
drawEnv();
}
function resetTutorial() {
document.getElementById('tutorialOverlay').classList.remove('hidden');
setStep(1);
document.getElementById('waveType').value = 'sine';
document.getElementById('filterType').value = 'lowpass';
updateOsc(); updateFilter(); updateEnv(); updateLFO(); updateEffects();
}
/* ===== Init ===== */
initKeys();
updateOsc();
updateFilter();
updateEnv();
updateLFO();
updateEffects();
// Auto-restart audio on first interaction
window.addEventListener('click', () => {
if (ctx.state === 'suspended') ctx.resume();
}, {once: true});
// Draw on resize
window.addEventListener('resize', () => { drawOsc(); drawFilter(); drawEnv(); });
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingmachines/inkling","provider":"openrouter","persona":"full","sessionId":"73508054-0ed7-408c-b837-1e3ffa94c12b","tokensIn":71284,"tokensOut":14389,"tokensTotal":85673,"turns":4,"toolCalls":3,"failedToolCalls":0,"timestamp":"2026-07-18T22:11:28.156Z"} -->