TutSynth
inkling · basic

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 35,480 bytes · SHA-256 758e32dc487b
<!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 by Building</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Inter:wght@300;400;600&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #05070a;
--surface: #0b0e14;
--surface-2: #11141d;
--surface-3: #181c28;
--text: #e8edf2;
--text-dim: #8b9cb3;
--accent: #39d0ff;
--accent-2: #9f3cff;
--accent-3: #ff6b6b;
--warn: #ffb84d;
--success: #4ade80;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
/* Background pattern */
body::before {
content: "";
position: fixed;
inset: 0;
background:
radial-gradient(circle at 20% 30%, rgba(57,208,255,0.06) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(159,60,255,0.06) 0%, transparent 40%),
radial-gradient(circle at 50% 50%, rgba(255,107,107,0.03) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
/* Header */
header {
position: relative;
z-index: 10;
padding: 2rem 2rem 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.05);
background: rgba(11,14,20,0.7);
backdrop-filter: blur(12px);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
flex-wrap: wrap;
}
.title-area h1 {
font-family: 'Orbitron', sans-serif;
font-size: clamp(1.8rem, 4vw, 2.6rem);
letter-spacing: 0.08em;
background: linear-gradient(135deg, var(--accent), var(--accent-2), var(--accent-3));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.1;
}
.title-area p {
color: var(--text-dim);
font-size: 0.95rem;
margin-top: 0.25rem;
}
.progress-bar {
width: 260px;
height: 6px;
background: var(--surface-3);
border-radius: 3px;
overflow: hidden;
margin-top: 0.75rem;
position: relative;
border: 1px solid rgba(255,255,255,0.04);
}
.progress-fill {
position: absolute;
top: 0; left: 0; bottom: 0;
width: 20%;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
border-radius: 3px;
transition: width 0.6s ease;
box-shadow: 0 0 10px rgba(57,208,255,0.4);
}
/* Main layout */
main {
position: relative;
z-index: 2;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 2rem 4rem;
display: grid;
grid-template-columns: 1fr 340px;
gap: 2rem;
align-items: start;
}
@media (max-width: 960px) {
main { grid-template-columns: 1fr; }
.header-inner { flex-direction: column; align-items: flex-start; }
}
/* Left: Synth & Keyboard */
.synth-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Lesson card */
.lesson-card {
background: var(--surface);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
padding: 1.5rem 1.75rem;
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
position: relative;
overflow: hidden;
}
.lesson-card::after {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--accent-3));
}
.lesson-card h2 {
font-family: 'Orbitron', sans-serif;
font-size: 1.3rem;
margin-bottom: 0.5rem;
}
.lesson-card .lesson-id {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
background: rgba(57,208,255,0.08);
border: 1px solid rgba(57,208,255,0.15);
padding: 0.15rem 0.5rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.lesson-card p {
color: var(--text-dim);
line-height: 1.7;
font-size: 0.95rem;
}
/* Synth panel */
.synth-box {
background: linear-gradient(160deg, #0f1118, #0a0c12);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 20px;
padding: 1.75rem;
box-shadow: 0 30px 80px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.03);
position: relative;
}
.synth-box::before {
content: "";
position: absolute;
top: 12px; left: 12px; right: 12px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
}
/* Module sections */
.module {
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.module:last-child { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; }
.module.locked {
opacity: 0.35;
pointer-events: none;
filter: grayscale(0.8);
position: relative;
}
.module.locked::after {
content: "LOCKED — Complete previous stage";
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8);
color: var(--warn);
padding: 0.3rem 0.8rem;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
white-space: nowrap;
letter-spacing: 0.05em;
pointer-events: none;
}
.module h3 {
font-family: 'Orbitron', sans-serif;
font-size: 1rem;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
font-size: 0.65rem;
background: var(--accent-2);
color: #fff;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.04em;
}
/* Controls */
.controls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.control-group label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 0.3rem;
font-weight: 600;
}
.slider-wrap {
position: relative;
}
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 6px;
background: var(--surface-3);
border-radius: 3px;
outline: none;
border: none;
transition: background 0.2s;
}
input[type="range"]:hover { background: #232738; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
cursor: pointer;
box-shadow: 0 2px 8px rgba(57,208,255,0.35), 0 0 0 2px rgba(57,208,255,0.15);
transition: transform 0.1s;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
cursor: pointer;
box-shadow: 0 2px 8px rgba(57,208,255,0.35);
border: none;
}
.value-label {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--accent);
margin-top: 0.2rem;
}
/* Waveform buttons */
.wave-btns {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.wave-btn {
flex: 1 1 auto;
padding: 0.35rem 0.5rem;
border: 1px solid rgba(255,255,255,0.08);
background: var(--surface-2);
color: var(--text-dim);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.wave-btn.active {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
border-color: transparent;
box-shadow: 0 0 10px rgba(57,208,255,0.25);
}
/* Visualizer */
.viz-area {
margin-top: 1rem;
height: 80px;
background: linear-gradient(180deg, rgba(57,208,255,0.04) 0%, transparent 100%);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
position: relative;
overflow: hidden;
}
.viz-area canvas {
width: 100%;
height: 100%;
display: block;
}
/* Keyboard */
.keyboard-wrap {
margin-top: 0.5rem;
}
.keyboard {
display: flex;
height: 140px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 10px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.03);
}
.key {
flex: 1;
background: linear-gradient(180deg, #1a1d27, #0f1118);
border-right: 1px solid rgba(255,255,255,0.05);
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 0.8rem;
cursor: pointer;
user-select: none;
transition: background 0.1s;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: var(--text-dim);
}
.key:last-child { border-right: 0; }
.key:hover { background: linear-gradient(180deg, #252b3a, #181c28); }
.key:active, .key.active {
background: linear-gradient(180deg, var(--accent), #1a8fcf);
color: #fff;
box-shadow: inset 0 0 20px rgba(57,208,255,0.3);
}
/* Right: Stages sidebar */
.stages-sidebar {
position: sticky;
top: 2rem;
}
.stage-card {
background: var(--surface);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
transition: all 0.3s ease;
}
.stage-card.completed {
border-color: rgba(74,222,128,0.35);
box-shadow: 0 0 20px rgba(74,222,128,0.1);
}
.stage-card.completed .stage-num {
background: var(--success);
color: #fff;
}
.stage-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: 50%;
background: var(--surface-3);
color: var(--text-dim);
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.stage-card h3 {
font-family: 'Orbitron', sans-serif;
font-size: 1rem;
margin-bottom: 0.25rem;
}
.stage-card p {
font-size: 0.85rem;
color: var(--text-dim);
line-height: 1.5;
}
.stage-card.completed::before {
content: "✓";
position: absolute;
top: -6px; right: -6px;
background: var(--success);
color: #fff;
width: 24px; height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.7rem;
box-shadow: 0 2px 8px rgba(74,222,128,0.4);
}
/* Unlock button */
.unlock-btn {
width: 100%;
padding: 0.75rem;
margin-top: 0.5rem;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
font-family: 'Orbitron', sans-serif;
font-size: 0.9rem;
letter-spacing: 0.08em;
cursor: pointer;
box-shadow: 0 4px 20px rgba(57,208,255,0.2);
transition: all 0.2s;
}
.unlock-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(57,208,255,0.35);
}
/* Footer */
footer {
position: relative;
z-index: 2;
text-align: center;
padding: 2rem;
color: var(--text-dim);
font-size: 0.8rem;
border-top: 1px solid rgba(255,255,255,0.04);
}
/* Responsive adjustments */
@media (max-width: 700px) {
.key { font-size: 0.6rem; }
.controls-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="title-area">
<h1>SYNTH PLAYGROUND</h1>
<p>A progressive guide to synthesis — learn by playing</p>
</div>
<div>
<div style="font-family:'JetBrains Mono',monospace; font-size:0.8rem; color:var(--text-dim); text-align:right;">PROGRESS</div>
<div class="progress-bar" aria-label="Course progress">
<div id="progressFill" class="progress-fill" style="width:20%"></div>
</div>
</div>
</div>
</header>
<main>
<section class="synth-section" aria-label="Synth and lessons">
<!-- Lesson 1 -->
<article class="lesson-card" id="lesson-1">
<span class="lesson-id">STAGE 01 — SOUND SOURCE</span>
<h2>Oscillators: The Heart of Sound</h2>
<p>Every synth starts with an oscillator — a repeating waveform. Sine sounds pure like a flute. Sawtooth is bright and buzzy like a string. Square is hollow and woodwind-like. Triangle is softer and flute-like.</p>
<p><strong>Try it:</strong> Click the keyboard below. Switch waveforms and hear how the basic tone changes. This is the raw material before any shaping.</p>
</article>
<!-- Synth Box -->
<div class="synth-box" id="synthBox">
<!-- Module: Oscillator -->
<div class="module" id="mod-osc">
<h3>OSCILLATOR <span class="badge">STAGE 1</span></h3>
<div class="controls-grid">
<div class="control-group">
<label for="waveSelect">Waveform</label>
<div class="wave-btns">
<button class="wave-btn active" onclick="setWave('sine')" aria-pressed="true" aria-label="Sine wave">SINE</button>
<button class="wave-btn" onclick="setWave('sawtooth')" aria-pressed="false" aria-label="Sawtooth wave">SAW</button>
<button class="wave-btn" onclick="setWave('square')" aria-pressed="false" aria-label="Square wave">SQUARE</button>
<button class="wave-btn" onclick="setWave('triangle')" aria-pressed="false" aria-label="Triangle wave">TRIANGLE</button>
</div>
</div>
<div class="control-group">
<label for="freqRange">Pitch (semitones)</label>
<div class="slider-wrap">
<input type="range" id="freqRange" min="-24" max="24" value="0" step="1" oninput="updatePitch(this.value)">
<div class="value-label" id="pitchVal">0</div>
</div>
</div>
</div>
</div>
<!-- Module: Envelope -->
<div class="module locked" id="mod-env">
<h3>AMPLITUDE ENVELOPE <span class="badge">STAGE 2</span></h3>
<p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Attack = how quickly sound reaches full volume. Decay = how fast it falls. Sustain = holding level. Release = fade out time.</p>
<div class="controls-grid">
<div class="control-group">
<label for="atkRange">Attack</label>
<input type="range" id="atkRange" min="0.001" max="2" step="0.01" value="0.05" oninput="updateEnv()">
<div class="value-label" id="atkVal">0.05 s</div>
</div>
<div class="control-group">
<label for="decRange">Decay</label>
<input type="range" id="decRange" min="0.01" max="2" step="0.01" value="0.3" oninput="updateEnv()">
<div class="value-label" id="decVal">0.30 s</div>
</div>
<div class="control-group">
<label for="susRange">Sustain</label>
<input type="range" id="susRange" min="0" max="1" step="0.05" value="0.7" oninput="updateEnv()">
<div class="value-label" id="susVal">0.70</div>
</div>
<div class="control-group">
<label for="relRange">Release</label>
<input type="range" id="relRange" min="0.01" max="3" step="0.01" value="0.5" oninput="updateEnv()">
<div class="value-label" id="relVal">0.50 s</div>
</div>
</div>
</div>
<!-- Module: Filter -->
<div class="module locked" id="mod-filt">
<h3>FILTER <span class="badge">STAGE 3</span></h3>
<p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Filters shape tone by removing frequencies. A lowpass lets bass through and removes high sparkle. Resonance boosts the cutoff edge.</p>
<div class="controls-grid">
<div class="control-group">
<label for="cutoffRange">Cutoff (Hz)</label>
<input type="range" id="cutoffRange" min="100" max="8000" step="10" value="3000" oninput="updateFilter()">
<div class="value-label" id="cutVal">3000 Hz</div>
</div>
<div class="control-group">
<label for="resRange">Resonance</label>
<input type="range" id="resRange" min="0" max="20" step="0.5" value="2" oninput="updateFilter()">
<div class="value-label" id="resVal">2.0</div>
</div>
</div>
</div>
<!-- Module: LFO -->
<div class="module locked" id="mod-lfo">
<h3>LFO MODULATION <span class="badge">STAGE 4</span></h3>
<p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">A Low-Frequency Oscillator slowly changes a parameter over time — like a tremolo (volume wobble) or vibrato (pitch wobble).</p>
<div class="controls-grid">
<div class="control-group">
<label for="lfoRateRange">LFO Rate (Hz)</label>
<input type="range" id="lfoRateRange" min="0.1" max="10" step="0.1" value="2" oninput="updateLFO()">
<div class="value-label" id="lfoRateVal">2.0 Hz</div>
</div>
<div class="control-group">
<label for="lfoDepthRange">Depth</label>
<input type="range" id="lfoDepthRange" min="0" max="1" step="0.05" value="0.3" oninput="updateLFO()">
<div class="value-label" id="lfoDepthVal">0.30</div>
</div>
<div class="control-group">
<label for="lfoTarget">Target</label>
<div class="wave-btns">
<button class="wave-btn active" onclick="setLFO('pitch')" aria-pressed="true">PITCH</button>
<button class="wave-btn" onclick="setLFO('filter')" aria-pressed="false">FILTER</button>
</div>
</div>
</div>
</div>
<!-- Visualizer -->
<div class="viz-area">
<canvas id="scopeCanvas" aria-label="Oscilloscope visualization"></canvas>
</div>
</div>
<!-- Keyboard -->
<div class="keyboard-wrap">
<h3 style="font-family:'Orbitron',sans-serif; font-size:0.85rem; letter-spacing:0.08em; margin-bottom:0.5rem;">KEYBOARD <span style="color:var(--text-dim); font-family:'Inter',sans-serif; font-size:0.8rem; font-weight:400;">(Mouse / Touch)</span></h3>
<div class="keyboard" role="group" aria-label="Musical keyboard">
<button class="key" onmousedown="playNote('C4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="C4">C</button>
<button class="key" onmousedown="playNote('D4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="D4">D</button>
<button class="key" onmousedown="playNote('E4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="E4">E</button>
<button class="key" onmousedown="playNote('F4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="F4">F</button>
<button class="key" onmousedown="playNote('G4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="G4">G</button>
<button class="key" onmousedown="playNote('A4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="A4">A</button>
<button class="key" onmousedown="playNote('B4')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="B4">B</button>
<button class="key" onmousedown="playNote('C5')" onmouseup="releaseNote()" onmouseleave="releaseNote()" aria-label="C5">C+</button>
</div>
</div>
</section>
<!-- Right sidebar: stages -->
<aside class="stages-sidebar" aria-label="Learning stages">
<div class="stage-card" id="stage-card-1">
<div class="stage-num">1</div>
<h3>Oscillator</h3>
<p>Choose waveform shape. Sine = pure, Saw = bright, Square = hollow, Triangle = soft.</p>
</div>
<div class="stage-card" id="stage-card-2">
<div class="stage-num">2</div>
<h3>Envelope (ADSR)</h3>
<p>Shape the volume over time with Attack, Decay, Sustain, and Release.</p>
<button class="unlock-btn" onclick="unlockStage(2)">Unlock Stage 2</button>
</div>
<div class="stage-card" id="stage-card-3">
<div class="stage-num">3</div>
<h3>Filter</h3>
<p>Remove high frequencies with a lowpass filter. Resonance creates a sharp peak.</p>
<button class="unlock-btn" onclick="unlockStage(3)">Unlock Stage 3</button>
</div>
<div class="stage-card" id="stage-card-4">
<div class="stage-num">4</div>
<h3>LFO Modulation</h3>
<p>Use a slow oscillator to wobble pitch or filter over time.</p>
<button class="unlock-btn" onclick="unlockStage(4)">Unlock Stage 4</button>
</div>
<div class="stage-card" id="stage-card-5">
<div class="stage-num">5</div>
<h3>Polyphony</h3>
<p>Play multiple notes at once. Each voice is a complete synth chain.</p>
<button class="unlock-btn" onclick="unlockStage(5)">Unlock Polyphony</button>
</div>
</aside>
</main>
<footer>
<p>Built with the Web Audio API. No external synth libraries — pure browser synthesis.</p>
</footer>
<script>
// ========== STATE ==========
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let currentOsc = null;
let currentGain = null;
let currentFilter = null;
let currentNote = null;
// Wave selection
let waveType = 'sine';
// Envelope params
let atk = 0.05, dec = 0.3, sus = 0.7, rel = 0.5;
// Filter params
let cutoff = 3000, resonance = 2;
// LFO params
let lfoRate = 2, lfoDepth = 0.3, lfoTarget = 'pitch';
let lfoNode = null;
let lfoGain = null;
// Polyphony
let polyEnabled = false;
const activeVoices = new Map(); // key -> {osc, gain, filter, note}
// Note mapping
const noteFreq = {
'C4': 261.63, 'D4': 293.66, 'E4': 329.63, 'F4': 349.23,
'G4': 392.00, 'A4': 440.00, 'B4': 493.88, 'C5': 523.25
};
// ========== INIT ==========
function initAudio() {
if (audioCtx.state === 'suspended') audioCtx.resume();
}
// ========== WAVE ==========
function setWave(type) {
waveType = type;
document.querySelectorAll('.wave-btn').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
event.target.closest('.wave-btns').querySelectorAll('.wave-btn').forEach(b => {
if (b.textContent.toLowerCase().includes(type === 'sine' ? 'sine' : type === 'triangle' ? 'triangle' : type === 'square' ? 'square' : 'saw')) {
b.classList.add('active');
b.setAttribute('aria-pressed', 'true');
}
});
}
// ========== PITCH ==========
function updatePitch(val) {
document.getElementById('pitchVal').textContent = val;
}
// ========== ENVELOPE ==========
function updateEnv() {
atk = parseFloat(document.getElementById('atkRange').value);
dec = parseFloat(document.getElementById('decRange').value);
sus = parseFloat(document.getElementById('susRange').value);
rel = parseFloat(document.getElementById('relRange').value);
document.getElementById('atkVal').textContent = atk.toFixed(2) + ' s';
document.getElementById('decVal').textContent = dec.toFixed(2) + ' s';
document.getElementById('susVal').textContent = sus.toFixed(2);
document.getElementById('relVal').textContent = rel.toFixed(2) + ' s';
}
// ========== FILTER ==========
function updateFilter() {
cutoff = parseFloat(document.getElementById('cutoffRange').value);
resonance = parseFloat(document.getElementById('resRange').value);
document.getElementById('cutVal').textContent = Math.round(cutoff) + ' Hz';
document.getElementById('resVal').textContent = resonance.toFixed(1);
}
// ========== LFO ==========
function setLFO(target) {
lfoTarget = target;
document.querySelectorAll('#mod-lfo .wave-btn').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
event.target.closest('.wave-btns').querySelectorAll('.wave-btn').forEach(b => {
if ((b.textContent === 'PITCH' && target === 'pitch') || (b.textContent === 'FILTER' && target === 'filter')) {
b.classList.add('active');
b.setAttribute('aria-pressed', 'true');
}
});
}
function updateLFO() {
lfoRate = parseFloat(document.getElementById('lfoRateRange').value);
lfoDepth = parseFloat(document.getElementById('lfoDepthRange').value);
document.getElementById('lfoRateVal').textContent = lfoRate.toFixed(1) + ' Hz';
document.getElementById('lfoDepthVal').textContent = lfoDepth.toFixed(2);
}
// ========== PLAY ==========
function noteToFreq(note) {
const base = noteFreq[note] || 440;
const semitones = parseInt(document.getElementById('freqRange').value) || 0;
return base * Math.pow(2, semitones / 12);
}
function playNote(noteName) {
initAudio();
// Visual feedback
document.querySelectorAll('.key').forEach(k => {
if (k.textContent.includes('C') || k.textContent.includes('D') || k.textContent.includes('E') || k.textContent.includes('F') || k.textContent.includes('G') || k.textContent.includes('A') || k.textContent.includes('B')) {
const n = k.getAttribute('aria-label');
if (n === noteName) k.classList.add('active');
}
});
if (polyEnabled) {
playPolyVoice(noteName);
} else {
releaseNote();
playMonoNote(noteName);
}
}
function playMonoNote(noteName) {
currentNote = noteName;
const freq = noteToFreq(noteName);
const now = audioCtx.currentTime;
// Oscillator
currentOsc = audioCtx.createOscillator();
currentOsc.type = waveType;
currentOsc.frequency.value = freq;
// Filter
currentFilter = audioCtx.createBiquadFilter();
currentFilter.type = 'lowpass';
currentFilter.frequency.value = cutoff;
currentFilter.Q.value = resonance;
// Gain (envelope)
currentGain = audioCtx.createGain();
currentGain.gain.setValueAtTime(0, now);
currentGain.gain.linearRampToValueAtTime(1, now + atk);
currentGain.gain.linearRampToValueAtTime(sus, now + atk + dec);
// LFO
lfoNode = audioCtx.createOscillator();
lfoNode.type = 'sine';
lfoNode.frequency.value = lfoRate;
lfoGain = audioCtx.createGain();
lfoGain.gain.value = lfoDepth * 20; // scale for filter or pitch
if (lfoTarget === 'pitch') {
lfoGain.connect(currentOsc.frequency);
lfoNode.connect(lfoGain);
} else {
lfoGain.connect(currentFilter.frequency);
lfoNode.connect(lfoGain);
}
// Routing
currentOsc.connect(currentFilter);
currentFilter.connect(currentGain);
currentGain.connect(audioCtx.destination);
// Start
currentOsc.start();
lfoNode.start();
}
function playPolyVoice(noteName) {
const freq = noteToFreq(noteName);
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
osc.type = waveType;
osc.frequency.value = freq;
const flt = audioCtx.createBiquadFilter();
flt.type = 'lowpass';
flt.frequency.value = cutoff;
flt.Q.value = resonance;
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(1, now + atk);
gain.gain.linearRampToValueAtTime(sus, now + atk + dec);
// Connect LFO for poly too
const lfoOsc = audioCtx.createOscillator();
lfoOsc.type = 'sine';
lfoOsc.frequency.value = lfoRate;
const lfoG = audioCtx.createGain();
lfoG.gain.value = lfoDepth * 20;
if (lfoTarget === 'pitch') {
lfoG.connect(osc.frequency);
lfoOsc.connect(lfoG);
} else {
lfoG.connect(flt.frequency);
lfoOsc.connect(lfoG);
}
osc.connect(flt);
flt.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
lfoOsc.start();
activeVoices.set(noteName, { osc, gain, filter: flt, lfoOsc, lfoG });
}
function releaseNote() {
if (!polyEnabled && currentNote) {
const now = audioCtx.currentTime;
if (currentGain) {
currentGain.gain.cancelScheduledValues(now);
currentGain.gain.setValueAtTime(currentGain.gain.value, now);
currentGain.gain.exponentialRampToValueAtTime(0.001, now + rel);
if (currentOsc) currentOsc.stop(now + rel + 0.05);
}
}
// Clear keyboard UI
document.querySelectorAll('.key').forEach(k => k.classList.remove('active'));
// For poly: release all if no keys held? Simplified: just reset mono.
// In a full poly system we'd track held keys; for simplicity we'll clear voices after a delay.
if (polyEnabled) {
// Simplified poly release: immediately stop voices
for (const [k, v] of activeVoices) {
const now = audioCtx.currentTime;
v.gain.gain.cancelScheduledValues(now);
v.gain.gain.setValueAtTime(v.gain.gain.value, now);
v.gain.gain.exponentialRampToValueAtTime(0.001, now + rel);
v.osc.stop(now + rel + 0.05);
v.lfoOsc.stop(now + rel + 0.05);
}
activeVoices.clear();
}
}
// ========== STAGE PROGRESSION ==========
let unlocked = 1;
function unlockStage(n) {
if (n !== unlocked) return;
unlocked = n;
// Unlock module
const mod = document.getElementById('mod-' + (n === 2 ? 'env' : n === 3 ? 'filt' : n === 4 ? 'lfo' : 'poly'));
if (mod) {
mod.classList.remove('locked');
}
// Update card
const card = document.getElementById('stage-card-' + n);
if (card) card.classList.add('completed');
// Update progress bar
document.getElementById('progressFill').style.width = (n * 25) + '%';
// Poly special handling
if (n === 5) {
polyEnabled = true;
// Add a module for poly or just set state
// We'll rely on the stage card to show poly enabled
}
}
// ========== VISUALIZER ==========
const canvas = document.getElementById('scopeCanvas');
const ctx = canvas.getContext('2d');
let analyser;
let dataArray;
function initVisualizer() {
analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
dataArray = new Uint8Array(analyser.frequencyBinCount);
// We'll connect the main output through analyser when playing
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
function drawVisualizer() {
requestAnimationFrame(drawVisualizer);
if (!analyser) {
resizeCanvas();
ctx.clearRect(0, 0, canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio);
return;
}
analyser.getByteTimeDomainData(dataArray);
const w = canvas.width / window.devicePixelRatio;
const h = canvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
ctx.beginPath();
const sliceWidth = w / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0; // 0..2 range roughly
const y = (v * h / 2) + h / 4;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(57,208,255,0.8)';
ctx.shadowColor = 'rgba(57,208,255,0.5)';
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
}
// Connect analyser when playing
const originalPlayMonoNote = playMonoNote;
playMonoNote = function(noteName) {
originalPlayMonoNote(noteName);
if (!analyser) initVisualizer();
if (currentGain && analyser) {
// Disconnect and reconnect through analyser
currentGain.disconnect();
currentGain.connect(analyser);
analyser.connect(audioCtx.destination);
}
resizeCanvas();
};
window.addEventListener('resize', resizeCanvas);
drawVisualizer();
resizeCanvas();
// Keyboard events
document.addEventListener('keydown', (e) => {
const keyMap = { 'a':'C4','w':'D4','s':'E4','e':'F4','d':'G4','f':'A4','t':'B4','g':'C5' };
const note = keyMap[e.key.toLowerCase()];
if (note) {
e.preventDefault();
playNote(note);
const btn = Array.from(document.querySelectorAll('.key')).find(b => b.getAttribute('aria-label') === note);
if (btn) btn.classList.add('active');
}
});
document.addEventListener('keyup', (e) => {
const keyMap = { 'a':'C4','w':'D4','s':'E4','e':'F4','d':'G4','f':'A4','t':'B4','g':'C5' };
if (keyMap[e.key.toLowerCase()]) {
releaseNote();
}
});
// Initialize first stage as active
document.getElementById('mod-env').classList.add('locked');
document.getElementById('mod-filt').classList.add('locked');
document.getElementById('mod-lfo').classList.add('locked');
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingmachines/inkling","provider":"openrouter","persona":"basic","sessionId":"4d7799da-0dbd-4c45-a415-6c73f37c9b6e","tokensIn":222,"tokensOut":10183,"tokensTotal":10405,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-07-18T20:56:18.750Z"} -->