Skip to content

Instantly share code, notes, and snippets.

@yves-chevallier
Created December 25, 2025 09:01
Show Gist options
  • Select an option

  • Save yves-chevallier/3aaff186c2280a9dc16ba2995688c55d to your computer and use it in GitHub Desktop.

Select an option

Save yves-chevallier/3aaff186c2280a9dc16ba2995688c55d to your computer and use it in GitHub Desktop.
Synthétiseur
<!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