Created
December 25, 2025 09:01
-
-
Save yves-chevallier/3aaff186c2280a9dc16ba2995688c55d to your computer and use it in GitHub Desktop.
Synthétiseur
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Synthé Clavier SDL → Web</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=Space+Grotesk:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" | |
| rel="stylesheet" | |
| /> | |
| <style> | |
| :root { | |
| --bg-1: #0c1017; | |
| --bg-2: #141a24; | |
| --panel: rgba(24, 32, 45, 0.75); | |
| --panel-border: rgba(255, 255, 255, 0.12); | |
| --accent: #37f3ab; | |
| --accent-2: #3cbcff; | |
| --accent-3: #ffb247; | |
| --text: #e9edf4; | |
| --muted: #98a3b3; | |
| --shadow: 0 20px 50px rgba(0, 0, 0, 0.35); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| margin: 0; | |
| font-family: "Space Grotesk", sans-serif; | |
| color: var(--text); | |
| background: radial-gradient( | |
| 1200px 700px at 10% 10%, | |
| rgba(60, 188, 255, 0.15), | |
| transparent 55% | |
| ), | |
| radial-gradient( | |
| 900px 600px at 90% 15%, | |
| rgba(55, 243, 171, 0.12), | |
| transparent 60% | |
| ), | |
| linear-gradient(160deg, var(--bg-1), var(--bg-2)); | |
| min-height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| padding: 32px 18px 48px; | |
| } | |
| .app { | |
| width: min(1100px, 100%); | |
| display: grid; | |
| gap: 20px; | |
| } | |
| header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 16px; | |
| padding: 16px 22px; | |
| background: var(--panel); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 18px; | |
| box-shadow: var(--shadow); | |
| } | |
| header h1 { | |
| font-size: clamp(20px, 2vw, 26px); | |
| margin: 0; | |
| letter-spacing: 0.02em; | |
| } | |
| header .meta { | |
| font-size: 13px; | |
| color: var(--muted); | |
| max-width: 440px; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| } | |
| button { | |
| cursor: pointer; | |
| border: none; | |
| border-radius: 12px; | |
| padding: 10px 16px; | |
| font-weight: 600; | |
| background: linear-gradient(135deg, var(--accent), var(--accent-2)); | |
| color: #081318; | |
| box-shadow: 0 12px 25px rgba(60, 188, 255, 0.35); | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 16px 35px rgba(60, 188, 255, 0.45); | |
| } | |
| .panel { | |
| background: var(--panel); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 22px; | |
| padding: 22px; | |
| box-shadow: var(--shadow); | |
| } | |
| .wave-wrap { | |
| position: relative; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 420px; | |
| border-radius: 16px; | |
| background: rgba(10, 16, 25, 0.8); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| } | |
| .legend { | |
| display: flex; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| font-size: 13px; | |
| color: var(--muted); | |
| margin-top: 12px; | |
| } | |
| .octaves { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 14px; | |
| } | |
| .octave { | |
| width: 44px; | |
| height: 32px; | |
| border-radius: 10px; | |
| background: rgba(255, 255, 255, 0.12); | |
| display: grid; | |
| place-items: center; | |
| font-weight: 600; | |
| color: #c7cdda; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| transition: background 0.2s ease, color 0.2s ease; | |
| } | |
| .octave.active { | |
| background: linear-gradient(140deg, var(--accent-3), var(--accent)); | |
| color: #0d1217; | |
| } | |
| .keyboard { | |
| position: relative; | |
| height: 190px; | |
| margin-top: 10px; | |
| display: flex; | |
| align-items: flex-end; | |
| } | |
| .white-key, | |
| .black-key { | |
| position: relative; | |
| border-radius: 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| display: grid; | |
| place-items: end center; | |
| padding-bottom: 14px; | |
| font-family: "Share Tech Mono", monospace; | |
| font-size: 14px; | |
| letter-spacing: 0.05em; | |
| user-select: none; | |
| } | |
| .white-key { | |
| flex: 1; | |
| height: 170px; | |
| margin: 0 4px; | |
| background: linear-gradient(180deg, #fdfdfd, #cfd5dd); | |
| color: #11151b; | |
| box-shadow: inset 0 -8px 12px rgba(0, 0, 0, 0.12); | |
| } | |
| .white-key.active { | |
| background: linear-gradient(180deg, #ffffff, #88f0b7); | |
| box-shadow: inset 0 -10px 18px rgba(55, 243, 171, 0.35); | |
| } | |
| .black-key { | |
| width: 42px; | |
| height: 100px; | |
| background: linear-gradient(180deg, #111822, #050709); | |
| color: #ffffff; | |
| position: absolute; | |
| top: 0; | |
| transform: translateX(-50%); | |
| box-shadow: inset 0 -8px 10px rgba(0, 0, 0, 0.45); | |
| } | |
| .black-key.active { | |
| background: linear-gradient(180deg, #3cbcff, #1b2a3c); | |
| } | |
| .hint { | |
| color: var(--muted); | |
| font-size: 13px; | |
| margin-top: 8px; | |
| } | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| background: rgba(55, 243, 171, 0.12); | |
| color: var(--accent); | |
| font-weight: 600; | |
| font-size: 12px; | |
| } | |
| @media (max-width: 800px) { | |
| header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| } | |
| .controls { | |
| width: 100%; | |
| justify-content: space-between; | |
| } | |
| canvas { | |
| height: 320px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header> | |
| <div> | |
| <div class="badge">SDL → Web Audio</div> | |
| <h1>Synthé clavier interactif</h1> | |
| <div class="meta"> | |
| Joue les notes avec le clavier, change l'octave avec Y/X, et déclenche des accords | |
| avec 1/4/5/7. Le rendu reproduit l'oscillateur sinusoïdal et l'enveloppe du programme SDL. | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button id="start">Activer l'audio</button> | |
| </div> | |
| </header> | |
| <section class="panel"> | |
| <div class="octaves" id="octaves"></div> | |
| <div class="wave-wrap"> | |
| <canvas id="scope" width="1024" height="500"></canvas> | |
| </div> | |
| <div class="legend"> | |
| <div>Touches: A W S E D F T G Z H U J K O L</div> | |
| <div>Octave: Y (--) · X (++)</div> | |
| <div>Accords: 1 (C) · 4 (F) · 5 (G) · 7 (C7)</div> | |
| </div> | |
| </section> | |
| <section class="panel"> | |
| <div class="keyboard" id="keyboard"></div> | |
| <div class="hint"> | |
| Astuce: clique n'importe où pour autoriser l'audio si le navigateur bloque le démarrage. | |
| </div> | |
| </section> | |
| </div> | |
| <script> | |
| const SAMPLE_RATE = 44100; | |
| const SAMPLES_IN_BUFFER = 1024; | |
| const AMPLITUDE = 5000; | |
| const AMPLITUDE_MAX = 32768; | |
| const NUM_NOTES = 15; | |
| const REF_FREQUENCY = 261.6255653; | |
| const OCTAVE_MIN = 0; | |
| const OCTAVE_DEFAULT = 3; | |
| const OCTAVE_MAX = 5; | |
| const TAU = 0.5; | |
| const TICK_TIME = 0.001; | |
| const keyMap = [ | |
| "a", | |
| "w", | |
| "s", | |
| "e", | |
| "d", | |
| "f", | |
| "t", | |
| "g", | |
| "z", | |
| "h", | |
| "u", | |
| "j", | |
| "k", | |
| "o", | |
| "l", | |
| ]; | |
| const chordKeys = { | |
| "1": [0, 4, 7], // C | |
| "4": [0, 5, 9], // F | |
| "5": [2, 7, 11], // G | |
| "7": [0, 4, 7, 10], // C7 | |
| }; | |
| const notes = new Array(NUM_NOTES).fill(0).map((_, i) => { | |
| return REF_FREQUENCY * Math.pow(Math.pow(2, 1 / 12), i); | |
| }); | |
| const on = new Array(NUM_NOTES).fill(false); | |
| const env = new Array(NUM_NOTES).fill(1); | |
| let octave = OCTAVE_DEFAULT; | |
| let lastTime = performance.now(); | |
| let audioCtx = null; | |
| let isAudioReady = false; | |
| const oscNodes = new Array(NUM_NOTES).fill(null); | |
| const gainNodes = new Array(NUM_NOTES).fill(null); | |
| const pressedKeys = new Set(); | |
| const canvas = document.getElementById("scope"); | |
| const ctx = canvas.getContext("2d"); | |
| const octavesEl = document.getElementById("octaves"); | |
| const keyboardEl = document.getElementById("keyboard"); | |
| function buildOctaves() { | |
| octavesEl.innerHTML = ""; | |
| for (let i = OCTAVE_MIN; i <= OCTAVE_MAX; i += 1) { | |
| const chip = document.createElement("div"); | |
| chip.className = "octave" + (i === octave ? " active" : ""); | |
| chip.textContent = i; | |
| octavesEl.appendChild(chip); | |
| } | |
| } | |
| function buildKeyboard() { | |
| keyboardEl.innerHTML = ""; | |
| const whiteKeys = ["a", "s", "d", "f", "g", "h", "j", "k", "l"]; | |
| const blackKeys = [ | |
| { key: "w", offset: 0.5 }, | |
| { key: "e", offset: 1.5 }, | |
| { key: "t", offset: 3.5 }, | |
| { key: "z", offset: 4.5 }, | |
| { key: "u", offset: 5.5 }, | |
| { key: "o", offset: 7.5 }, | |
| ]; | |
| whiteKeys.forEach((key) => { | |
| const div = document.createElement("div"); | |
| div.className = "white-key"; | |
| div.dataset.key = key; | |
| div.textContent = key.toUpperCase(); | |
| keyboardEl.appendChild(div); | |
| }); | |
| const whiteWidth = keyboardEl.clientWidth / whiteKeys.length; | |
| blackKeys.forEach(({ key, offset }) => { | |
| const div = document.createElement("div"); | |
| div.className = "black-key"; | |
| div.dataset.key = key; | |
| div.style.left = `${whiteWidth * offset + 4}px`; | |
| div.textContent = key.toUpperCase(); | |
| keyboardEl.appendChild(div); | |
| }); | |
| } | |
| function updateKeyStyles() { | |
| document.querySelectorAll(".white-key, .black-key").forEach((el) => { | |
| const key = el.dataset.key; | |
| const idx = keyMap.indexOf(key); | |
| if (idx >= 0 && on[idx]) { | |
| el.classList.add("active"); | |
| } else { | |
| el.classList.remove("active"); | |
| } | |
| }); | |
| } | |
| function ensureAudio() { | |
| if (audioCtx) return; | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| function startNote(idx) { | |
| if (!audioCtx || oscNodes[idx]) return; | |
| const osc = audioCtx.createOscillator(); | |
| const gain = audioCtx.createGain(); | |
| osc.type = "sine"; | |
| osc.frequency.value = notes[idx] * Math.pow(2, octave - OCTAVE_DEFAULT); | |
| gain.gain.value = env[idx]; | |
| osc.connect(gain).connect(audioCtx.destination); | |
| osc.start(); | |
| oscNodes[idx] = osc; | |
| gainNodes[idx] = gain; | |
| } | |
| function stopNote(idx) { | |
| const osc = oscNodes[idx]; | |
| if (!osc) return; | |
| osc.stop(); | |
| osc.disconnect(); | |
| oscNodes[idx] = null; | |
| gainNodes[idx] = null; | |
| } | |
| function handleKeyChange(idx, isOn) { | |
| on[idx] = isOn; | |
| if (isOn) { | |
| startNote(idx); | |
| } else { | |
| env[idx] = 1; | |
| stopNote(idx); | |
| } | |
| } | |
| function handleChord(key, isOn) { | |
| const chord = chordKeys[key]; | |
| if (!chord) return; | |
| chord.forEach((idx) => handleKeyChange(idx, isOn)); | |
| } | |
| function setOctave(newOctave) { | |
| octave = Math.max(OCTAVE_MIN, Math.min(OCTAVE_MAX, newOctave)); | |
| buildOctaves(); | |
| for (let i = 0; i < NUM_NOTES; i += 1) { | |
| if (oscNodes[i]) { | |
| oscNodes[i].frequency.value = | |
| notes[i] * Math.pow(2, octave - OCTAVE_DEFAULT); | |
| } | |
| } | |
| } | |
| function drawWaveform() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = "rgba(10, 16, 25, 0.95)"; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.strokeStyle = "rgba(255,255,255,0.2)"; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, canvas.height / 2); | |
| ctx.lineTo(canvas.width, canvas.height / 2); | |
| ctx.stroke(); | |
| const buffer = new Float32Array(SAMPLES_IN_BUFFER); | |
| for (let j = 0; j < NUM_NOTES; j += 1) { | |
| if (!on[j]) continue; | |
| for (let i = 0; i < SAMPLES_IN_BUFFER; i += 1) { | |
| const phase = | |
| (2 * Math.PI * notes[j] * Math.pow(2, octave - OCTAVE_DEFAULT) * i) / | |
| SAMPLE_RATE; | |
| buffer[i] += AMPLITUDE * env[j] * Math.sin(phase); | |
| } | |
| } | |
| let lastTick = Infinity; | |
| for (let i = 1; i < SAMPLES_IN_BUFFER; i += 1) { | |
| const t = i / SAMPLE_RATE; | |
| if (t % TICK_TIME < lastTick) { | |
| ctx.strokeStyle = "rgba(255,255,255,0.2)"; | |
| ctx.beginPath(); | |
| ctx.moveTo((i * canvas.width) / SAMPLES_IN_BUFFER, canvas.height / 2 - 3); | |
| ctx.lineTo((i * canvas.width) / SAMPLES_IN_BUFFER, canvas.height / 2 + 3); | |
| ctx.stroke(); | |
| } | |
| lastTick = t % TICK_TIME; | |
| ctx.strokeStyle = "#37f3ab"; | |
| ctx.beginPath(); | |
| ctx.moveTo( | |
| ((i - 1) * canvas.width) / SAMPLES_IN_BUFFER, | |
| canvas.height / 2 - (buffer[i - 1] / AMPLITUDE_MAX) * (canvas.height * 0.38) | |
| ); | |
| ctx.lineTo( | |
| (i * canvas.width) / SAMPLES_IN_BUFFER, | |
| canvas.height / 2 - (buffer[i] / AMPLITUDE_MAX) * (canvas.height * 0.38) | |
| ); | |
| ctx.stroke(); | |
| } | |
| } | |
| function tick() { | |
| const now = performance.now(); | |
| const dt = (now - lastTime) / 1000; | |
| lastTime = now; | |
| for (let i = 0; i < NUM_NOTES; i += 1) { | |
| if (on[i]) { | |
| env[i] *= Math.exp(-dt / TAU); | |
| if (env[i] < 1e-3) env[i] = 0; | |
| if (gainNodes[i]) gainNodes[i].gain.value = env[i]; | |
| } | |
| } | |
| drawWaveform(); | |
| updateKeyStyles(); | |
| requestAnimationFrame(tick); | |
| } | |
| document.getElementById("start").addEventListener("click", async () => { | |
| ensureAudio(); | |
| if (audioCtx.state !== "running") { | |
| await audioCtx.resume(); | |
| } | |
| isAudioReady = true; | |
| }); | |
| window.addEventListener("keydown", (event) => { | |
| const key = event.key.toLowerCase(); | |
| if (pressedKeys.has(key)) return; | |
| pressedKeys.add(key); | |
| if (!isAudioReady) { | |
| ensureAudio(); | |
| audioCtx.resume(); | |
| isAudioReady = true; | |
| } | |
| if (key === "y") setOctave(octave - 1); | |
| if (key === "x") setOctave(octave + 1); | |
| const idx = keyMap.indexOf(key); | |
| if (idx >= 0) handleKeyChange(idx, true); | |
| if (chordKeys[key]) handleChord(key, true); | |
| }); | |
| window.addEventListener("keyup", (event) => { | |
| const key = event.key.toLowerCase(); | |
| pressedKeys.delete(key); | |
| const idx = keyMap.indexOf(key); | |
| if (idx >= 0) handleKeyChange(idx, false); | |
| if (chordKeys[key]) handleChord(key, false); | |
| }); | |
| window.addEventListener("resize", () => { | |
| buildKeyboard(); | |
| }); | |
| buildOctaves(); | |
| buildKeyboard(); | |
| tick(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment