← TutSynth results

TutSynth

Gemma 4 31b · composer

Initial view of TutSynth

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 20,294 bytes · SHA-256 d859e2b93198
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SynthLab: Learn Synthesis</title>
<style>
/* ── css: layout ── */

:root {
    --bg-color: #1a1a2e;
    --panel-color: #16213e;
    --accent-color: #4ecca3;
    --text-color: #e94560;
    --light-text: #eeeeee;
}

body {
    background-color: var(--bg-color);
    color: var(--light-text);
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    margin: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    overflow: hidden;
}

#app {
    width: 95vw;
    max-width: 1100px;
    background: var(--panel-color);
    padding: 2rem;
    border-radius: 20px;
    box-shadow: 0 20px 50px rgba(0,0,0,0.5);
    border: 1px solid #333;
}

header {
    text-align: center;
    margin-bottom: 2rem;
}

#main-container {
    display: grid;
    grid-template-columns: 1fr 350px;
    gap: 2rem;
}

#synth-interface {
    background: #0f3460;
    padding: 2rem;
    border-radius: 15px;
    border: 3px solid #000;
    box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
}

#controls-area {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
    gap: 1.5rem;
    margin-bottom: 2rem;
}

.control-group {
    background: rgba(0,0,0,0.3);
    padding: 1rem;
    border-radius: 10px;
    border-left: 4px solid var(--accent-color);
}

.control-group h4 {
    margin: 0 0 10px 0;
    font-size: 0.9rem;
    color: var(--accent-color);
    text-transform: uppercase;
}

.slider-row {
    display: flex;
    flex-direction: column;
    margin-bottom: 10px;
}

.slider-row label {
    font-size: 0.8rem;
    margin-bottom: 5px;
    display: flex;
    justify-content: space-between;
}

input[type=range] {
    width: 100%;
    cursor: pointer;
}

#keyboard {
    display: flex;
    justify-content: center;
    padding: 20px 0;
    background: #000;
    border-radius: 10px;
    overflow-x: auto;
}

.key {
    width: 40px;
    height: 150px;
    border: 1px solid #000;
    background: white;
    color: black;
    cursor: pointer;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    padding-bottom: 10px;
    border-radius: 0 0 5px 5px;
    user-select: none;
    transition: background 0.1s;
}

.key.black {
    background: #333;
    color: white;
    width: 30px;
    height: 100px;
    margin-left: -15px;
    margin-right: -15px;
    z-index: 2;
}

.key:active, .key.active {
    background: var(--accent-color);
}

#lesson-panel {
    background: rgba(255,255,255,0.05);
    padding: 1.5rem;
    border-radius: 15px;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
}

#lesson-content h2 {
    margin-top: 0;
    color: var(--accent-color);
}

#lesson-nav {
    display: flex;
    justify-content: space-between;
    margin-top: 2rem;
}

button {
    padding: 10px 20px;
    border-radius: 5px;
    border: none;
    background: var(--accent-color);
    color: #1a1a2e;
    font-weight: bold;
    cursor: pointer;
    transition: opacity 0.2s;
}

button:disabled {
    opacity: 0.3;
    cursor: not-allowed;
}

#viz-container {
    height: 100px;
    background: #000;
    margin-bottom: 1rem;
    border-radius: 10px;
    border: 1px solid #333;
    overflow: hidden;
}

canvas {
    width: 100%;
    height: 100%;
}
</style>
</head>
<body>
<div id="unlock-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; text-align: center; padding: 20px;">
    <h2 style="color: var(--accent-color); font-size: 2.5rem; margin-bottom: 1rem;">Ready to make some noise?</h2>
    <p style="font-size: 1.2rem; color: #ccc;">Click anywhere to activate the synthesizer</p>
    <div style="margin-top: 2rem; font-size: 3rem;">🔊</div>
</div>

<div id="app">
    <header>
        <h1 style="margin: 0; display: inline-block; vertical-align: middle;">🎹 SynthLab</h1>
        <p id="tutorial-text" style="display: inline-block; margin-left: 20px; font-style: italic; color: #666;">Interactive Synthesis Learning</p>
    </header>
    
    <div id="main-container">
        <div id="synth-interface">
            <div id="controls-area">
                <!-- Controls will be injected here by JS based on current lesson -->
            </div>
            <div id="keyboard">
                <!-- Keys will be injected here -->
            </div>
        </div>
        
        <div id="lesson-panel">
            <div id="lesson-content">
                <h2 id="lesson-title">Lesson 1: The Oscillator</h2>
                <p id="lesson-description">The heart of any synth is the Oscillator. It generates a repeating waveform. Different shapes create different "timbres" (tones).</p>
            </div>
            <div id="lesson-nav">
                <button id="prev-btn" disabled="">Previous</button>
                <button id="next-btn">Next Lesson</button>
            </div>
        </div>
    </div>
</div>
<script>
'use strict';

// ── const: SYNTH_STATE ──
const SYNTH_STATE = {
    currentStep: 0,
    waveType: 'sine',
    adsr: {
        attack: 0.1,
        decay: 0.2,
        sustain: 0.5,
        release: 0.8
    },
    filter: {
        cutoff: 2000,
        resonance: 1
    }
};

// ── const: STEPS ──
const STEPS = [
    {
        title: "Lesson 1: The Oscillator",
        description: "The Oscillator is where sound begins. It produces a raw waveform. Try switching between Sine (smooth), Square (hollow), Sawtooth (bright), and Triangle (muted).",
        controls: ['waveType'],
        highlight: 'Oscillator'
    },
    {
        title: "Lesson 2: The Filter",
        description: "A Low-Pass Filter removes high frequencies, making a sound 'darker'. Increasing resonance (Q) adds a peak at the cutoff frequency, creating a 'nasal' or 'squelchy' sound.",
        controls: ['waveType', 'filterCutoff', 'filterRes'],
        highlight: 'Filter'
    },
    {
        title: "Lesson 3: The Envelope (ADSR)",
        description: "ADSR (Attack, Decay, Sustain, Release) shapes how a sound evolves over time. Attack is the fade-in, Decay is the drop to Sustain level, and Release is the fade-out after you let go.",
        controls: ['waveType', 'filterCutoff', 'filterRes', 'attack', 'decay', 'sustain', 'release'],
        highlight: 'Envelope'
    }
];

// ── const: CONTROL_DEFS ──
const CONTROL_DEFS = {
    waveType: { label: 'Waveform', type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], group: 'Oscillator' },
    filterCutoff: { label: 'Cutoff', type: 'range', min: 20, max: 10000, step: 1, unit: 'Hz', group: 'Filter', stateKey: 'filter', stateProp: 'cutoff' },
    filterRes: { label: 'Resonance', type: 'range', min: 0.1, max: 20, step: 0.1, unit: '', group: 'Filter', stateKey: 'filter', stateProp: 'resonance' },
    attack: { label: 'Attack', type: 'range', min: 0.01, max: 2, step: 0.01, unit: 's', group: 'Envelope', stateKey: 'adsr', stateProp: 'attack' },
    decay: { label: 'Decay', type: 'range', min: 0.01, max: 2, step: 0.01, unit: 's', group: 'Envelope', stateKey: 'adsr', stateProp: 'decay' },
    sustain: { label: 'Sustain', type: 'range', min: 0, max: 1, step: 0.01, unit: '', group: 'Envelope', stateKey: 'adsr', stateProp: 'sustain' },
    release: { label: 'Release', type: 'range', min: 0.01, max: 5, step: 0.01, unit: 's', group: 'Envelope', stateKey: 'adsr', stateProp: 'release' },
};

// ── function: initAudio ──
function initAudio(synth) {
    // This handles the "Resume AudioContext" requirement for modern browsers
    const unlock = () => {
        if (synth.ctx.state === 'suspended') {
            synth.ctx.resume().then(() => {
                console.log('AudioContext resumed');
                document.getElementById('unlock-overlay').style.display = 'none';
            });
        }
    };
    
    const overlay = document.getElementById('unlock-overlay');
    if (overlay) {
        overlay.onclick = unlock;
    }
    
    // Also try to resume on first key press
    window.addEventListener('mousedown', unlock, { once: true });
}

// ── function: playNote ──
function playNote(freq) {
  initAudio();
  const ctx = SYNTH_STATE.audioCtx;
  
  // Stop previous osc if any
  if (SYNTH_STATE.oscillator) {
    try { SYNTH_STATE.oscillator.stop(); } catch(e){}
  }
  
  const osc = ctx.createOscillator();
  osc.type = SYNTH_STATE.settings.waveform;
  osc.frequency.setValueAtTime(freq, ctx.currentTime);
  
  // Envelope application
  const now = ctx.currentTime;
  const { attack, decay, sustain } = SYNTH_STATE.settings;
  
  // Initial gain is 0
  SYNTH_STATE.gainNode.gain.cancelScheduledValues(now);
  SYNTH_STATE.gainNode.gain.setValueAtTime(0, now);
  // Attack
  SYNTH_STATE.gainNode.gain.linearRampToValueAtTime(0.5, now + attack);
  // Decay to sustain
  SYNTH_STATE.gainNode.gain.linearRampToValueAtTime(sustain * 0.5, now + attack + decay);
  
  // Filter
  SYNTH_STATE.filterNode.frequency.setValueAtTime(SYNTH_STATE.settings.cutoff, now);
  SYNTH_STATE.filterNode.Q.setValueAtTime(SYNTH_STATE.settings.resonance, now);
  
  osc.connect(SYNTH_STATE.filterNode);
  osc.start();
  SYNTH_STATE.oscillator = osc;
}

// ── function: stopNote ──
function stopNote() {
  if (!SYNTH_STATE.gainNode) return;
  const now = SYNTH_STATE.audioCtx.currentTime;
  const { release } = SYNTH_STATE.settings;
  
  SYNTH_STATE.gainNode.gain.cancelScheduledValues(now);
  SYNTH_STATE.gainNode.gain.setValueAtTime(SYNTH_STATE.gainNode.gain.value, now);
  SYNTH_STATE.gainNode.gain.linearRampToValueAtTime(0, now + release);
  
  // Stop oscillator after release
  const oldOsc = SYNTH_STATE.oscillator;
  setTimeout(() => {
    if (oldOsc) try { oldOsc.stop(); } catch(e){}
  }, release * 1000 + 100);
}

// ── function: createKeyboard ──
function createKeyboard() {
    const kb = document.getElementById('keyboard');
    const notes = [
        { note: 'C', freq: 261.63, black: false },
        { note: 'C#', freq: 277.18, black: true },
        { note: 'D', freq: 293.66, black: false },
        { note: 'D#', freq: 311.13, black: true },
        { note: 'E', freq: 329.63, black: false },
        { note: 'F', freq: 349.23, black: false },
        { note: 'F#', freq: 369.99, black: true },
        { note: 'G', freq: 392.00, black: false },
        { note: 'G#', freq: 415.30, black: true },
        { note: 'A', freq: 440.00, black: false },
        { note: 'A#', freq: 466.16, black: true },
        { note: 'B', freq: 493.88, black: false },
        { note: 'C2', freq: 523.25, black: false },
    ];
    
    notes.forEach(n => {
        const el = document.createElement('div');
        el.className = `key ${n.black ? 'black' : 'white'}`;
        el.textContent = n.note;
        el.dataset.freq = n.freq;
        kb.appendChild(el);
    });
}

// ── function: updateTutorial ──
function updateTutorial(stepIdx, synth) {
    const step = STEPS[stepIdx];
    document.getElementById('lesson-title').textContent = step.title;
    document.getElementById('lesson-description').textContent = step.description;
    document.getElementById('prev-btn').disabled = stepIdx === 0;
    document.getElementById('next-btn').disabled = stepIdx === STEPS.length - 1;
    
    const controlsArea = document.getElementById('controls-area');
    controlsArea.innerHTML = '';
    
    const groups = {};
    step.controls.forEach(ctrlId => {
        const def = CONTROL_DEFS[ctrlId];
        if (!groups[def.group]) {
            groups[def.group] = document.createElement('div');
            groups[def.group].className = 'control-group';
            groups[def.group].innerHTML = `<h4>${def.group}</h4>`;
            controlsArea.appendChild(groups[def.group]);
        }
        
        const { container, input } = createControlElement(ctrlId, def);
        
        // Set initial value
        let val;
        if (ctrlId === 'waveType') val = SYNTH_STATE.waveType;
        else if (def.stateKey) val = SYNTH_STATE[def.stateKey][def.stateProp];
        
        input.value = val;
        const valDisplay = document.getElementById(`${ctrlId}-val`);
        if (valDisplay) valDisplay.textContent = `${val}${def.unit || ''}`;
        
        input.oninput = (e) => {
            const newValue = e.target.value;
            if (ctrlId === 'waveType') {
                SYNTH_STATE.waveType = newValue;
                window.currentWaveType = newValue;
            } else if (def.stateKey) {
                SYNTH_STATE[def.stateKey][def.stateProp] = parseFloat(newValue);
                if (def.stateKey === 'filter') {
                    synth.updateFilter(SYNTH_STATE.filter.cutoff, SYNTH_STATE.filter.resonance);
                }
            }
            if (valDisplay) valDisplay.textContent = `${newValue}${def.unit || ''}`;
        };
        
        groups[def.group].appendChild(container);
    });
}

// ── function: createControlElement ──
function createControlElement(id, def) {
    const container = document.createElement('div');
    container.className = 'slider-row';
    
    const labelDiv = document.createElement('div');
    labelDiv.className = 'slider-label';
    labelDiv.innerHTML = `<span>${def.label}</span><span id="${id}-val"></span>`;
    
    let input;
    if (def.type === 'select') {
        input = document.createElement('select');
        def.options.forEach(opt => {
            const o = document.createElement('option');
            o.value = opt;
            o.textContent = opt.charAt(0).toUpperCase() + opt.slice(1);
            input.appendChild(o);
        });
    } else {
        input = document.createElement('input');
        input.type = 'range';
        input.min = def.min;
        input.max = def.max;
        input.step = def.step;
    }
    
    input.id = id + '-slider';
    container.appendChild(labelDiv);
    container.appendChild(input);
    
    return { container, input, labelDiv };
}

// ── class: SynthEngine ──
class SynthEngine {
    constructor() {
        this.ctx = new (window.AudioContext || window.webkitAudioContext)();
        this.masterGain = this.ctx.createGain();
        this.masterGain.gain.value = 0.2;
        this.masterGain.connect(this.ctx.destination);

        this.oscillator = null;
        this.filter = null;
        this.env = null;
        
        this.setupFilter();
    }

    setupFilter() {
        this.filter = this.ctx.createBiquadFilter();
        this.filter.type = 'lowpass';
        this.filter.frequency.value = 20000;
        this.filter.Q.value = 1;
        this.filter.connect(this.masterGain);
    }

    playNote(freq) {
        if (this.ctx.state === 'suspended') {
            this.ctx.resume();
        }

        const osc = this.ctx.createOscillator();
        const noteGain = this.ctx.createGain();

        osc.type = window.currentWaveType || 'sine';
        osc.frequency.value = freq;

        // ADSR implementation via gainNode schedules
        const now = this.ctx.currentTime;
        const { attack, decay, sustain, release } = window.currentADSR;

        noteGain.gain.setValueAtTime(0, now);
        noteGain.gain.linearRampToValueAtTime(1, now + attack);
        noteGain.gain.linearRampToValueAtTime(sustain, now + attack + decay);

        osc.connect(noteGain);
        noteGain.connect(this.filter);
        
        osc.start();

        return { osc, noteGain, startTime: now };
    }

    stopNote(noteObj) {
        const now = this.ctx.currentTime;
        const { release } = window.currentADSR;
        
        noteObj.noteGain.gain.cancelScheduledValues(now);
        noteObj.noteGain.gain.setValueAtTime(noteObj.noteGain.gain.value, now);
        noteObj.noteGain.gain.linearRampToValueAtTime(0, now + release);
        
        noteObj.osc.stop(now + release + 0.1);
    }

    updateFilter(cutoff, res) {
        this.filter.frequency.setTargetAtTime(cutoff, this.ctx.currentTime, 0.05);
        this.filter.Q.setTargetAtTime(res, this.ctx.currentTime, 0.05);
    }
}

// ── function: createVisualizer ──
function createVisualizer(synth) {
    const vizContainer = document.createElement('div');
    vizContainer.id = 'viz-container';
    const canvas = document.createElement('canvas');
    canvas.id = 'viz-canvas';
    vizContainer.appendChild(canvas);
    
    const controlsArea = document.getElementById('controls-area');
    controlsArea.prepend(vizContainer);
    
    const ctx = canvas.getContext('2d');
    const analyzer = synth.ctx.createAnalyser();
    analyzer.fftSize = 2048;
    
    // Connect master gain to analyzer
    synth.masterGain.connect(analyzer);
    
    const bufferLength = analyzer.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);

    function draw() {
        requestAnimationFrame(draw);
        
        // Resize canvas to match display size
        if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
            canvas.width = canvas.clientWidth;
            canvas.height = canvas.clientHeight;
        }
        
        analyzer.getByteTimeDomainData(dataArray);
        
        ctx.fillStyle = 'black';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        
        ctx.lineWidth = 2;
        ctx.strokeStyle = '#4ecca3';
        ctx.beginPath();
        
        const sliceWidth = canvas.width / bufferLength;
        let x = 0;
        
        for (let i = 0; i < bufferLength; i++) {
            const v = dataArray[i] / 128.0;
            const y = (v * canvas.height) / 2;
            
            if (i === 0) ctx.moveTo(x, y);
            else ctx.lineTo(x, y);
            
            x += sliceWidth;
        }
        
        ctx.lineTo(canvas.width, canvas.height / 2);
        ctx.stroke();
    }
    
    draw();
}

// ── main ──

const synth = new SynthEngine();
window.currentWaveType = SYNTH_STATE.waveType;
window.currentADSR = SYNTH_STATE.adsr;

initAudio(synth);
createKeyboard();
createVisualizer(synth);
updateTutorial(SYNTH_STATE.currentStep, synth);

const activeNotes = new Map();

// Keyboard interactions
document.getElementById('keyboard').addEventListener('mousedown', (e) => {
    if (e.target.classList.contains('key')) {
        const freq = parseFloat(e.target.dataset.freq);
        const noteObj = synth.playNote(freq);
        activeNotes.set(e.target, noteObj);
        e.target.classList.add('active');
    }
});

document.getElementById('keyboard').addEventListener('mouseup', (e) => {
    if (e.target.classList.contains('key')) {
        const noteObj = activeNotes.get(e.target);
        if (noteObj) {
            synth.stopNote(noteObj);
            activeNotes.delete(e.target);
        }
        e.target.classList.remove('active');
    }
});

document.getElementById('keyboard').addEventListener('mouseleave', (e) => {
    if (e.target.classList.contains('key')) {
        const noteObj = activeNotes.get(e.target);
        if (noteObj) {
            synth.stopNote(noteObj);
            activeNotes.delete(e.target);
        }
        e.target.classList.remove('active');
    }
});

// Navigation
document.getElementById('next-btn').onclick = () => {
    if (SYNTH_STATE.currentStep < STEPS.length - 1) {
        SYNTH_STATE.currentStep++;
        updateTutorial(SYNTH_STATE.currentStep, synth);
    }
};

document.getElementById('prev-btn').onclick = () => {
    if (SYNTH_STATE.currentStep > 0) {
        SYNTH_STATE.currentStep--;
        updateTutorial(SYNTH_STATE.currentStep, synth);
    }
};

// Keep ADSR synced
setInterval(() => {
    window.currentADSR = SYNTH_STATE.adsr;
    window.currentWaveType = SYNTH_STATE.waveType;
}, 100);

// Initial Filter Setup
synth.updateFilter(SYNTH_STATE.filter.cutoff, SYNTH_STATE.filter.resonance);
</script>
</body>
</html>
<!-- agent-meta {"model":"gemma-4-31b","provider":"cerebras","persona":"composer","sessionId":"1111a602-1188-40fe-a153-72eaa3c68ef3","tokensIn":176667,"tokensOut":7695,"tokensTotal":184362,"turns":19,"toolCalls":18,"failedToolCalls":2,"timestamp":"2026-07-15T01:45:42.551Z"} -->