History of post ‘What If You Could Hear Your Code?’

Versions are sorted from most recent to oldest with the original version at the bottom. Changes are highlighted relative to the next, i.e. older, version underneath. Only changed lines and their surrounding lines are shown, except for the original version, which is shown in full.

Revision 4 · · View this (the most recent) version (v5)

Show the demo only where WebGL is on

@@ -156,7 +156,6 @@ void main() {
.synesthete-demo .sd-stage { font-size: 1.1rem; font-weight: 700; letter-spacing: 0.05em; }
.synesthete-demo .sd-detail, .synesthete-demo .sd-last { font-size: 0.65rem; opacity: 0.75; }
.synesthete-demo .sd-last { opacity: 0; margin-top: 4px; }
-.synesthete-demo .sd-missing { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; padding: 1rem; text-align: center; color: rgba(255, 255, 255, 0.8); }
.synesthete-demo .sd-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; margin-top: 0.75rem; }
.synesthete-demo .sd-quality { flex: 1 1 14rem; display: flex; align-items: center; gap: 0.5rem; margin: 0; color: var(--muted); font-variant-caps: all-small-caps; letter-spacing: 0.06em; }
.synesthete-demo .sd-quality input { flex: 1; min-width: 0; accent-color: var(--chocolate); }
@@ -180,8 +179,15 @@ void main() {
    return e;
  }
  root.classList.add('synesthete-demo');
  +// Without WebGL, there's no demo, just a word on how to see it.
+  const canvas = element('canvas');
+  const gl = canvas.getContext('webgl', {antialias: false});
+  if (!gl) {
+    root.appendChild(element('p', null, 'To see this demo, turn on WebGL in your browser.'));
+    return;
+  }
  const screen = root.appendChild(element('div', 'sd-screen'));
  -const canvas = screen.appendChild(element('canvas'));+screen.appendChild(canvas);
  const hud = screen.appendChild(element('div', 'sd-hud'));
  const stageLabel = hud.appendChild(element('div', 'sd-stage'));
  const detail = hud.appendChild(element('div', 'sd-detail'));
@@ -277,11 +283,6 @@ void main() {
    return shader;
  }
  function startTunnel() {
-    const gl = canvas.getContext('webgl', {antialias: false});
-    if (!gl) {
-      screen.appendChild(element('p', 'sd-missing', 'Your browser can’t show the tunnel, but you can still listen.'));
-      return;
-    }
    const program = gl.createProgram();
    gl.attachShader(program, compile(gl, gl.VERTEX_SHADER,
      'attribute vec2 p; void main() { gl_Position = vec4(p, 0.0, 1.0); }'));
@@ -357,14 +358,6 @@ void main() {
    }
  }
  // ---- The music -----------------------------------------------------------
-  // Lockdown Mode on iPhones turns off the Web Audio API along with WebGL.
-  // Without it, the music plays from <audio> elements, which iPhones couldn't
-  // make jump from clip to clip in time. So each stage's bars were mixed
-  // ahead of time into a loop of their own, and each accent cut into a file
-  // of its own, in /assets/synesthete-demo/.
-  const WebAudio = window.AudioContext || window.webkitAudioContext;
-  const FILES_FOR = '/assets/synesthete-demo/';
-  let loops = null, loopStage = 0;  // without Web Audio: an element for each stage's loop
  let context = null, master = null, audio = null;  // audio: the decoded file, once loaded
  let playing = false, timer = null;
  let nextBar = 0, bar = 0, barStage = null;
@@ -387,42 +380,6 @@ void main() {
    sources.add(source);
    source.onended = () => sources.delete(source);
  }
-  // The loop for the stage the code is in plays, from wherever it was, and
-  // the others are paused. Each loop is first played muted from the click
-  // that starts the sound, as iPhones require, so it may play later.
-  function syncLoops() {
-    loops.forEach((element, i) => {
-      if (!element.unlocked) return;
-      if (playing && i === loopStage) {
-        element.muted = false;
-        if (element.paused) element.play().catch(failed);
-      } else {
-        element.pause();
-        element.muted = false;
-      }
-    });
-  }
-  function startLoops() {
-    loopStage = stageOf(health);
-    if (loops) return syncLoops();
-    loops = STAGES.map((_, i) => {
-      const element = new Audio(FILES_FOR + 'loop-' + i + '.mp3');
-      element.loop = true;
-      element.muted = true;
-      element.play().then(() => {
-        element.unlocked = true;
-        syncLoops();
-      }, failed);
-      return element;
-    });
-  }
-  function failed() {
-    if (loops) loops.forEach(element => element.pause());
-    loops = null;  // to start over on the next click
-    if (!playing) return;
-    stopSound();
-    sound.textContent = 'Couldn’t load the sound';
-  }
  // Bars play back to back, walking through the code, in whatever stage the
  // code is in when each one starts. A bar is queued just before it's due, so
  // the slider is heard at the next bar line.
@@ -441,9 +398,8 @@ void main() {
    playing = true;
    sound.textContent = 'Stop sound';
    sound.setAttribute('aria-pressed', 'true');
-    if (!WebAudio) return startLoops();
    if (!context) {
      context = new -WebAudio();+(window.AudioContext || window.webkitAudioContext)();
      master = context.createGain();
      master.connect(context.destination);
    }
@@ -472,10 +428,6 @@ void main() {
    sound.textContent = 'Play sound';
    sound.setAttribute('aria-pressed', 'false');
    clearInterval(timer);
-    if (!WebAudio) {
-      if (loops) syncLoops();
-      return;
-    }
    if (!context) return;
    const t = context.currentTime;
    master.gain.cancelScheduledValues(t);
@@ -486,24 +438,11 @@ void main() {
  }
  // When the stage changes, the next bar starts right away, as in the plugin.
  function hurry() {
-    if (!WebAudio) {
-      if (playing && loops) {
-        loopStage = stageOf(health);
-        syncLoops();
-      }
-      return;
-    }
    if (!playing || !context || barStage === null || stageOf(health) === barStage) return;
    if (nextBar - context.currentTime > 0.3) {
      nextBar = context.currentTime + 0.05;
    }
  }
-  // Without Web Audio, from the click itself, as iPhones require
-  function accentFile(way) {
-    const stage = stageOf(health);
-    const turn = accentTurn++ % CLIPS.accents[stage][way].length;
-    new Audio(FILES_FOR + 'accent-' + stage + '-' + way + '-' + turn + '.mp3').play().catch(() => {});
-  }
  async function accent(way) {
    const buffer = await load();
    const choices = CLIPS.accents[stageOf(health)][way];
@@ -523,12 +462,6 @@ void main() {
    let pick = Math.random() * total;
    const file = files.find(f => (pick -= f.lines) < 0) || files[0];
    changed(file, way);
-    if (!WebAudio) {
-      startSound();
-      accentFile(way);
-      hurry();
-      return;
-    }
    startSound().then(() => accent(way)).then(hurry).catch(() => {});  // the button says if it failed
  }
  function change(way) {

Revision 3 · · View this version (v4)

Play the demo’s music from loops where Web Audio is off

@@ -358,13 +358,14 @@ void main() {
  }
  // ---- The music -----------------------------------------------------------
  // Lockdown Mode on iPhones turns off the Web Audio API along with WebGL.
  // Without it, the -clips play+music plays from <audio> -elements instead: three that take+elements, which iPhones couldn't
  // -turns with the bars, so+make jump from clip to clip in time. So each -bar's tail rings on under the next,+stage's bars were mixed
+  // ahead of time into a loop of their own, and -one+each accent cut into a file
  // -for accents.+of its own, in /assets/synesthete-demo/.
  const WebAudio = window.AudioContext || window.webkitAudioContext;
  const -TURNS+FILES_FOR = -3;+'/assets/synesthete-demo/';
+  let loops = null, loopStage = 0;  // without Web Audio: an element for each stage's loop
  let context = null, master = null, audio = null;  // audio: the decoded file, once loaded
-  let barPlayers = [], accentPlayer = null, unlocked = null;  // without Web Audio
  let playing = false, timer = null;
  let nextBar = 0, bar = 0, barStage = null;
  let accentTurn = 0;
@@ -386,46 +387,41 @@ void main() {
    sources.add(source);
    source.onended = () => sources.delete(source);
  }
  // -Plays clips from an <audio> element. Going to a clip takes iPhones a
-  // moment, so it's done ahead of time, while the element is paused: it's
-  // cued+The loop for the -clip+stage the code is in plays, from wherever it -plays next.
-  function clipPlayer() {
-    const element = new Audio(AUDIO);
-    let timeout = null, cued = null;
-    function cue(clip) {
-      if (cued === clip) return;
-      element.currentTime = clip.at;
-      cued = clip;
-    }
-    return {+was, and
  // -From+the others are paused. Each loop is first played muted from the click
  +// that starts the sound, as iPhones require, so -the
-      // element+it may play -later
-      unlock()+later.
+  function syncLoops() {
    -element.muted = true;
-        return element.play().then(()+loops.forEach((element, i) => {
      -element.pause();+if (!element.unlocked) return;
+      if (playing && i === loopStage) {
        element.muted = false;-});
-      },
-      cue,
-      // next: the clip to cue once this one's done
-      play(clip, next) {
-        clearTimeout(timeout);
-        cue(clip);
-        cued = null;
-        return element.play().then(() => {
-          timeout = setTimeout(() => {
-            element.pause();
        if -(next) cue(next());
-          }, clip.length * 1000);
-        });
-      },
-      stop()+(element.paused) element.play().catch(failed);
+      } else {-clearTimeout(timeout);
        element.pause();
        -cued+element.muted = -null;+false;
      }
    -};+});
+  }
+  function startLoops() {
+    loopStage = stageOf(health);
+    if (loops) return syncLoops();
+    loops = STAGES.map((_, i) => {
+      const element = new Audio(FILES_FOR + 'loop-' + i + '.mp3');
+      element.loop = true;
+      element.muted = true;
+      element.play().then(() => {
+        element.unlocked = true;
+        syncLoops();
+      }, failed);
+      return element;
+    });
+  }
+  function failed() {
+    if (loops) loops.forEach(element => element.pause());
+    loops = null;  // to start over on the next click
+    if (!playing) return;
+    stopSound();
+    sound.textContent = 'Couldn’t load the sound';
  }
  // Bars play back to back, walking through the code, in whatever stage the
  // code is in when each one starts. A bar is queued just before it's due, so
@@ -440,46 +436,12 @@ void main() {
      bar++;
    }
  }
-  function barClip(n) {
-    const bars = CLIPS.bars[stageOf(health)];
-    return bars[n % bars.length];
-  }
-  function startClips() {
-    if (!unlocked) {
-      barPlayers = Array.from({length: TURNS}, clipPlayer);
-      accentPlayer = clipPlayer();
-      unlocked = Promise.all([...barPlayers, accentPlayer].map(p => p.unlock()));
-    }
-    return unlocked.then(() => {
-      if (!playing) return;
-      for (let i = 0; i < TURNS; i++) barPlayers[(bar + i) % TURNS].cue(barClip(bar + i));
-      nextBar = now();
-      scheduleClips();
-      timer = setInterval(scheduleClips, 50);
-    }, () => {
-      unlocked = null;
-      failed();
-    });
-  }
-  function scheduleClips() {
-    if (now() < nextBar) return;
-    barStage = stageOf(health);
-    const n = bar;
-    const clip = barClip(n);
-    barPlayers[n % TURNS].play(clip, () => barClip(n + TURNS)).catch(failed);
-    nextBar = Math.max(nextBar + clip.period, now());
-    bar++;
-  }
-  function failed() {
-    stopSound();
-    sound.textContent = 'Couldn’t load the sound';
-  }
  async function startSound() {
    if (playing) return;
    playing = true;
    sound.textContent = 'Stop sound';
    sound.setAttribute('aria-pressed', 'true');
    if (!WebAudio) return -startClips();+startLoops();
    if (!context) {
      context = new WebAudio();
      master = context.createGain();
@@ -511,8 +473,7 @@ void main() {
    sound.setAttribute('aria-pressed', 'false');
    clearInterval(timer);
    if (!WebAudio) {
-barPlayers.forEach(p => p.stop());      if -(accentPlayer) accentPlayer.stop();+(loops) syncLoops();
      return;
    }
    if (!context) return;
@@ -525,18 +486,28 @@ void main() {
  }
  // When the stage changes, the next bar starts right away, as in the plugin.
  function hurry() {
    if +(!WebAudio) {
+      if (playing && loops) {
+        loopStage = stageOf(health);
+        syncLoops();
+      }
+      return;
+    }
+    if (!playing || -(WebAudio && !context)+!context || barStage === null || stageOf(health) === barStage) return;-const t = WebAudio ? context.currentTime : now();
    if (nextBar - -t+context.currentTime > 0.3) {
      nextBar = -t+context.currentTime + 0.05;
    }
  }
  +// Without Web Audio, from the click itself, as iPhones require
+  function accentFile(way) {
+    const stage = stageOf(health);
+    const turn = accentTurn++ % CLIPS.accents[stage][way].length;
+    new Audio(FILES_FOR + 'accent-' + stage + '-' + way + '-' + turn + '.mp3').play().catch(() => {});
+  }
  async function accent(way) {
-    const choices = CLIPS.accents[stageOf(health)][way];
-    const clip = choices[accentTurn++ % choices.length];
-    if (!WebAudio) return accentPlayer.play(clip);
    const buffer = await load();
    +const choices = CLIPS.accents[stageOf(health)][way];
    play(buffer, -clip,+choices[accentTurn++ % choices.length], context.currentTime);
  }
  // ---- Controls ------------------------------------------------------------
  sound.addEventListener('click', () => playing ? stopSound() : startSound());
@@ -552,6 +523,12 @@ void main() {
    let pick = Math.random() * total;
    const file = files.find(f => (pick -= f.lines) < 0) || files[0];
    changed(file, way);
    +if (!WebAudio) {
+      startSound();
+      accentFile(way);
+      hurry();
+      return;
+    }
    startSound().then(() => accent(way)).then(hurry).catch(() => {});  // the button says if it failed
  }
  function change(way) {

Revision 2 · · View this version (v3)

Keep the demo’s sound from stuttering where Web Audio is off

@@ -358,11 +358,13 @@ void main() {
  }
  // ---- The music -----------------------------------------------------------
  // Lockdown Mode on iPhones turns off the Web Audio API along with WebGL.
  // Without it, the clips play from-two <audio> elements instead: -one for the+three that take
  // +turns with the bars, +so each -cutting off+bar's tail rings on under the -one before's tail,+next, and one
  +// for accents.
  const WebAudio = window.AudioContext || window.webkitAudioContext;
  +const TURNS = 3;
  let context = null, master = null, audio = null;  // audio: the decoded file, once loaded
  let -barPlayer+barPlayers = -null,+[], accentPlayer = +null, unlocked = null;  // without Web Audio
  let playing = false, timer = null;
  let nextBar = 0, bar = 0, barStage = null;
  let accentTurn = 0;
@@ -384,30 +386,44 @@ void main() {
    sources.add(source);
    source.onended = () => sources.delete(source);
  }
  // Plays -one clip at a time+clips from an <audio> -element, from+element. Going to a clip takes iPhones a
+  // moment, so it's done ahead of time, while the -click that+element is paused: it's
  // -starts it, as iPhones require. Until+cued for the -file can seek,+clip it plays -muted,
-  // so the file's own start isn't heard.+next.
  function clipPlayer() {
    const element = new Audio(AUDIO);
    let timeout = +null, cued = null;
    +function cue(clip) {
+      if (cued === clip) return;
+      element.currentTime = clip.at;
+      cued = clip;
+    }
    return {
      -play(clip)+// From the click that starts the sound, as iPhones require, so the
+      // element may play later
+      unlock() {
+        element.muted = true;
+        return element.play().then(() => {
+          element.pause();
+          element.muted = false;
+        });
+      },
+      cue,
+      // next: the clip to cue once this one's done
+      play(clip, next) {
        clearTimeout(timeout);
        -const ready+cue(clip);
+        cued = -element.readyState >= 1;
-        if (ready) element.currentTime = clip.at;
-        element.muted = !ready;+null;
        return -(element.paused ? element.play() : Promise.resolve()).then(()+element.play().then(() => {-if (!ready) {
-            element.currentTime = clip.at;
-            element.muted = false;
-          }
-          clearTimeout(timeout);
          timeout = setTimeout(() => -element.pause(),+{
+            element.pause();
+            if (next) cue(next());
+          }, clip.length * 1000);
        });
      },
      stop() {
        clearTimeout(timeout);
        element.pause();
        +cued = null;
      }
    };
  }
@@ -424,25 +440,40 @@ void main() {
      bar++;
    }
  }
  +function barClip(n) {
+    const bars = CLIPS.bars[stageOf(health)];
+    return bars[n % bars.length];
+  }
  function startClips() {
    -barPlayer+if (!unlocked) {
+      barPlayers = -barPlayer || clipPlayer();+Array.from({length: TURNS}, clipPlayer);
      accentPlayer =-accentPlayer || clipPlayer();
      +unlocked = Promise.all([...barPlayers, accentPlayer].map(p => p.unlock()));
+    }
+    return unlocked.then(() => {
+      if (!playing) return;
+      for (let i = 0; i < TURNS; i++) barPlayers[(bar + i) % TURNS].cue(barClip(bar + i));
      nextBar = now();
      scheduleClips();
      timer = setInterval(scheduleClips, 50);
    +}, () => {
+      unlocked = null;
+      failed();
+    });
  }
  function scheduleClips() {
    if (now() < nextBar) return;
    barStage = stageOf(health);
    const -bars+n = -CLIPS.bars[barStage];+bar;
    const clip = -bars[bar+barClip(n);
+    barPlayers[n % -bars.length];
-    barPlayer.play(clip).catch(()+TURNS].play(clip, () => -{
-      stopSound();
-      sound.textContent = 'Couldn’t load the sound';
-    });+barClip(n + TURNS)).catch(failed);
    nextBar = Math.max(nextBar + clip.period, now());
    bar++;
  }
  +function failed() {
+    stopSound();
+    sound.textContent = 'Couldn’t load the sound';
+  }
  async function startSound() {
    if (playing) return;
    playing = true;
@@ -480,7 +511,7 @@ void main() {
    sound.setAttribute('aria-pressed', 'false');
    clearInterval(timer);
    if (!WebAudio) {
      -if (barPlayer) barPlayer.stop();+barPlayers.forEach(p => p.stop());
      if (accentPlayer) accentPlayer.stop();
      return;
    }

Revision 1 · · View this version (v2)

Play the demo’s sound even where Web Audio is off

@@ -357,7 +357,12 @@ void main() {
    }
  }
  // ---- The music -----------------------------------------------------------
  +// Lockdown Mode on iPhones turns off the Web Audio API along with WebGL.
+  // Without it, the clips play from two <audio> elements instead: one for the
+  // bars, each cutting off the one before's tail, and one for accents.
+  const WebAudio = window.AudioContext || window.webkitAudioContext;
  let context = null, master = null, audio = null;  // audio: the decoded file, once loaded
  +let barPlayer = null, accentPlayer = null;  // without Web Audio
  let playing = false, timer = null;
  let nextBar = 0, bar = 0, barStage = null;
  let accentTurn = 0;
@@ -379,6 +384,33 @@ void main() {
    sources.add(source);
    source.onended = () => sources.delete(source);
  }
  +// Plays one clip at a time from an <audio> element, from the click that
+  // starts it, as iPhones require. Until the file can seek, it plays muted,
+  // so the file's own start isn't heard.
+  function clipPlayer() {
+    const element = new Audio(AUDIO);
+    let timeout = null;
+    return {
+      play(clip) {
+        clearTimeout(timeout);
+        const ready = element.readyState >= 1;
+        if (ready) element.currentTime = clip.at;
+        element.muted = !ready;
+        return (element.paused ? element.play() : Promise.resolve()).then(() => {
+          if (!ready) {
+            element.currentTime = clip.at;
+            element.muted = false;
+          }
+          clearTimeout(timeout);
+          timeout = setTimeout(() => element.pause(), clip.length * 1000);
+        });
+      },
+      stop() {
+        clearTimeout(timeout);
+        element.pause();
+      }
+    };
+  }
  // Bars play back to back, walking through the code, in whatever stage the
  // code is in when each one starts. A bar is queued just before it's due, so
  // the slider is heard at the next bar line.
@@ -392,13 +424,33 @@ void main() {
      bar++;
    }
  }
  +function startClips() {
+    barPlayer = barPlayer || clipPlayer();
+    accentPlayer = accentPlayer || clipPlayer();
+    nextBar = now();
+    scheduleClips();
+    timer = setInterval(scheduleClips, 50);
+  }
+  function scheduleClips() {
+    if (now() < nextBar) return;
+    barStage = stageOf(health);
+    const bars = CLIPS.bars[barStage];
+    const clip = bars[bar % bars.length];
+    barPlayer.play(clip).catch(() => {
+      stopSound();
+      sound.textContent = 'Couldn’t load the sound';
+    });
+    nextBar = Math.max(nextBar + clip.period, now());
+    bar++;
+  }
  async function startSound() {
    if (playing) return;
    playing = true;
    sound.textContent = 'Stop sound';
    sound.setAttribute('aria-pressed', 'true');
    +if (!WebAudio) return startClips();
    if (!context) {
      context = new -(window.AudioContext || window.webkitAudioContext)();+WebAudio();
      master = context.createGain();
      master.connect(context.destination);
    }
@@ -427,6 +479,11 @@ void main() {
    sound.textContent = 'Play sound';
    sound.setAttribute('aria-pressed', 'false');
    clearInterval(timer);
    +if (!WebAudio) {
+      if (barPlayer) barPlayer.stop();
+      if (accentPlayer) accentPlayer.stop();
+      return;
+    }
    if (!context) return;
    const t = context.currentTime;
    master.gain.cancelScheduledValues(t);
@@ -437,15 +494,18 @@ void main() {
  }
  // When the stage changes, the next bar starts right away, as in the plugin.
  function hurry() {
    if (!playing || -!context+(WebAudio && !context) || barStage === null || stageOf(health) === barStage) return;
    +const t = WebAudio ? context.currentTime : now();
    if (nextBar - -context.currentTime+t > 0.3) {
      nextBar = -context.currentTime+t + 0.05;
    }
  }
  async function accent(way) {
-    const buffer = await load();
    const choices = CLIPS.accents[stageOf(health)][way];
    -play(buffer,+const clip = choices[accentTurn++ % -choices.length],+choices.length];
+    if (!WebAudio) return accentPlayer.play(clip);
+    const buffer = await load();
+    play(buffer, clip, context.currentTime);
  }
  // ---- Controls ------------------------------------------------------------
  sound.addEventListener('click', () => playing ? stopSound() : startSound());

Original · · View this version (v1)

# What If You Could Hear Your Code?

*This post was written about 90% by me, 10% by AI.*

Over ten years ago, [Gavan Wilhite](https://x.com/GavanWilhite) and I had an idea: a subtle audio track playing inside Sublime Text should reflect your code quality.

We called it *Synesthesia* – named after the perceptual phenomenon where one sense activates another. Like when numbers or songs have ‘colors’. (As I recall, the name specifically was Gavan’s idea.)

But we never really got the project off the ground. It was too hard; I know nothing about audio engineering. At last, LLMs have put it within reach.

So I’m releasing it today, under a new name, since ‘Synesthesia’ was taken: [*Synesthete*](https://github.com/dchacke/synesthete). (That’s the word for someone who has synesthesia.) Here’s the sound of its own code, played from beautiful to cursed:

<audio controls src="/assets/synesthete-stages.mp3"></audio>

The music isn’t a canned track. It’s generated from the code itself, line by line, on recorded instruments, so every code base plays its own unique melody. It responds live as you make changes to your code.

Clean code is a harp over a cello section; as quality drops, it slides through marimba, piano and tubular bells down to a singing saw over trembling strings.

The sound is so soothing I actually found myself listening to it for hours as I was testing the package.

With agents, coding has become less about reading files yourself and more about getting a holistic overview of an entire code base. Agents code too quickly to review every single change line by line, and they’ve finally gotten good enough that coders don’t want to compromise on that speed.

<!-- ![](/assets/synesthete-visualizer.jpg) -->

Coders now want quick, inexplicit feedback – [lightning-like estimates](https://web.archive.org/web/20260316215023/https://www.aynrandlexicon.com/lexicon/emotions.html#:~:text=lightning%2Dlike%20estimates) about the code base *as a whole*. Synesthete gives you that, in the form of sound. It brings emotion into coding.

There’s a visual side, too: a map of every file, colored by its quality:

![File map](/assets/synesthete-map.png#plain)

… and a trippy tunnel in your browser that you fly through, one band per file, lighting up wherever the code changes – simulated below.

<script>
// A demo of Synesthete (https://github.com/dchacke/synesthete) for the post
// announcing it: Synesthete's own code as a tunnel and as music, with a slider
// for how good the code is and buttons that make changes to it.
//
// It goes in the post with <div id="synesthete-demo"></div>, before or after
// it, with its styles in it: the blog doesn't allow <style> tags in posts,
// but scripts may add style sheets. The blog turns "#" and digits after a
// space into links to comments, so colors here are named.
//
// The tunnel is Synesthete's visualizer, fed by the slider instead of by
// Sublime Text. The music can't be made in the browser, so it was rendered
// ahead of time by Synesthete's tools/render_demo.py: four bars of its own
// core/score.py in each stage, played back to back like the plugin plays them,
// and accents rising and falling in each stage. CLIPS says where each one
// starts in the audio file.
// It starts once the page has loaded, so the div may come after it.
((start) => document.readyState === 'loading'
  ? document.addEventListener('DOMContentLoaded', start) : start())(() => {
  'use strict';
  const root = document.getElementById('synesthete-demo');
  if (!root || root.dataset.started) return;
  root.dataset.started = 'yes';
  const AUDIO = '/assets/synesthete-demo.mp3';
  const CLIPS = {"bars": [[{"at": 0.0, "length": 4.58, "period": 2.08}, {"at": 4.83, "length": 4.58, "period": 2.08}, {"at": 9.66, "length": 4.58, "period": 2.08}, {"at": 14.49, "length": 4.58, "period": 2.08}], [{"at": 29.12, "length": 4.9, "period": 2.4}, {"at": 34.27, "length": 4.9, "period": 2.4}, {"at": 39.42, "length": 4.9, "period": 2.4}, {"at": 44.57, "length": 4.9, "period": 2.4}], [{"at": 59.52, "length": 5.14, "period": 2.64}, {"at": 64.91, "length": 5.14, "period": 2.64}, {"at": 70.3, "length": 5.14, "period": 2.64}, {"at": 75.69, "length": 5.14, "period": 2.64}], [{"at": 90.88, "length": 5.86, "period": 3.36}, {"at": 96.99, "length": 5.86, "period": 3.36}, {"at": 103.0999, "length": 5.86, "period": 3.36}, {"at": 109.2099, "length": 5.86, "period": 3.36}], [{"at": 125.1199, "length": 6.5, "period": 4.0}, {"at": 131.8699, "length": 6.5, "period": 4.0}, {"at": 138.6199, "length": 6.5, "period": 4.0}, {"at": 145.3699, "length": 6.5, "period": 4.0}], [{"at": 161.9199, "length": 6.9, "period": 4.4}, {"at": 169.0699, "length": 6.9, "period": 4.4}, {"at": 176.2199, "length": 6.9, "period": 4.4}, {"at": 183.3699, "length": 6.9, "period": 4.4}], [{"at": 200.3199, "length": 7.7, "period": 5.2}, {"at": 208.2699, "length": 7.7, "period": 5.2}, {"at": 216.2199, "length": 7.7, "period": 5.2}, {"at": 224.1699, "length": 7.7, "period": 5.2}]], "accents": [{"up": [{"at": 19.32, "length": 2.2}, {"at": 21.77, "length": 2.2}], "down": [{"at": 24.22, "length": 2.2}, {"at": 26.67, "length": 2.2}]}, {"up": [{"at": 49.72, "length": 2.2}, {"at": 52.17, "length": 2.2}], "down": [{"at": 54.62, "length": 2.2}, {"at": 57.07, "length": 2.2}]}, {"up": [{"at": 81.08, "length": 2.2}, {"at": 83.53, "length": 2.2}], "down": [{"at": 85.98, "length": 2.2}, {"at": 88.43, "length": 2.2}]}, {"up": [{"at": 115.3199, "length": 2.2}, {"at": 117.7699, "length": 2.2}], "down": [{"at": 120.2199, "length": 2.2}, {"at": 122.6699, "length": 2.2}]}, {"up": [{"at": 152.1199, "length": 2.2}, {"at": 154.5699, "length": 2.2}], "down": [{"at": 157.0199, "length": 2.2}, {"at": 159.4699, "length": 2.2}]}, {"up": [{"at": 190.5199, "length": 2.2}, {"at": 192.9699, "length": 2.2}], "down": [{"at": 195.4199, "length": 2.2}, {"at": 197.8699, "length": 2.2}]}, {"up": [{"at": 232.1199, "length": 2.2}, {"at": 234.5699, "length": 2.2}], "down": [{"at": 237.0199, "length": 2.2}, {"at": 239.4699, "length": 2.2}]}]};
  const STAGES = ['beautiful', 'good', 'decent', 'meh', 'bad', 'awful', 'cursed'];
  const THRESHOLDS = [0.9, 0.75, 0.6, 0.45, 0.3, 0.15, 0];
  const COLORS = [[76, 175, 80], [139, 195, 74], [205, 220, 57], [255, 193, 7],
                  [255, 152, 0], [244, 67, 54], [106, 27, 154]];
  // Synesthete's own source files, each a band around the tunnel as wide as
  // it is long
  const FILES = [
    ['synesthete.py', 861], ['tests/test_plugin.py', 594], ['core/music.py', 359],
    ['core/analysis.py', 286], ['core/watcher.py', 171], ['tools/prepare_samples.py', 149],
    ['tests/test_analysis.py', 147], ['core/mapview.py', 132], ['tests/test_music.py', 127],
    ['core/visualizer.py', 106], ['core/sampler.py', 105], ['tools/render_demo.py', 92],
    ['tests/test_mapview.py', 82], ['core/picture.py', 79], ['tests/test_watcher.py', 74],
    ['core/score.py', 66], ['core/player.py', 60], ['tests/test_score.py', 48],
    ['tests/test_visualizer.py', 47], ['tools/visualizer_demo.py', 45],
    ['tests/test_sampler.py', 42], ['core/checker.py', 39], ['tests/test_checker.py', 29]
  ];
  const START = 0.93;  // Synesthete's own score
  const STEP = 0.06;  // how much a change moves the score
  const MAP = 512;  // texels around the tunnel
  const FLASH = 4;  // seconds a changed file glows
  const PULSES = 8;
  // Nothing here may flash more than twice a second, to keep it safe for
  // people with photosensitive epilepsy.
  const FRAGMENT = `
precision highp float;
uniform vec2 res;
uniform float t;
uniform float health;
uniform float calm;  // 1 when the system asks for reduced motion
uniform sampler2D files;
uniform vec4 pulses[${PULSES}];  // angle 0-1, start time, strength, way (-1, 0, 1)
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  vec2 s = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), s.x),
             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), s.x), s.y);
}
vec3 palette(float x) {
  vec3 a = mix(vec3(0.28, 0.03, 0.10), vec3(0.5), health);
  vec3 b = mix(vec3(0.28, 0.05, 0.20), vec3(0.5), health);
  vec3 d = mix(vec3(0.00, 0.15, 0.35), vec3(0.00, 0.33, 0.67), health);
  return a + b * cos(6.28318 * (x + d));
}
float speed() { return (0.5 + 1.3 * health) * (1.0 - 0.5 * calm); }
vec3 tunnel(vec2 uv, float sick) {
  float r = max(length(uv), 0.001);
  float z = 0.4 / r + t * speed();
  float twist = 0.04 + 0.3 * sick * sick;
  float u = fract(atan(uv.y, uv.x) / 6.28318 + 0.5 + z * twist + 0.08 * sick * sin(z * 1.3 + t));
  vec4 file = texture2D(files, vec2(u, 0.5));
  float warp = noise(vec2(u * 12.0, z * 2.0 - t)) * sick;
  float wave = 0.5 + 0.5 * sin((z + warp) * 6.28318);
  float rings = pow(wave, 6.0);
  vec3 hue = palette(z * 0.12 + u * 2.0 + t * 0.05);
  vec3 col = mix(hue, file.rgb, 0.3) * (0.25 + 0.25 * wave) + hue * rings * 0.7;
  col += file.rgb * file.a;
  for (int i = 0; i < ${PULSES}; i++) {
    vec4 p = pulses[i];
    float age = t - p.y;
    if (p.z <= 0.0 || age < 0.0 || age > 5.0) continue;
    float pz = t * speed() + 0.6 + age * 2.5;
    float du = abs(fract(u - p.x + 0.5) - 0.5);
    float glow = exp(-du * du * 500.0) * exp(-(z - pz) * (z - pz) * 3.0) * (1.0 - age / 5.0);
    vec3 tint = p.w > 0.5 ? vec3(0.55, 1.0, 0.65) : (p.w < -0.5 ? vec3(1.0, 0.3, 0.25) : vec3(0.9));
    col += tint * glow * 1.2 * p.z;
  }
  float fog = smoothstep(0.0, 0.4, r);
  vec3 far = mix(vec3(0.10, 0.0, 0.03), vec3(1.3, 1.2, 1.1), health);
  return mix(far * 0.85, col, fog);
}
void main() {
  float sick = 1.0 - health;
  vec2 uv = (gl_FragCoord.xy - 0.5 * res) / res.y;
  uv += 0.12 * sick * (1.0 - calm) * vec2(sin(t * 0.9), cos(t * 0.7));
  float tick = floor(t * 2.0);
  float band = floor(gl_FragCoord.y / (20.0 + 40.0 * hash(vec2(tick, 1.0))));
  float tear = step(1.0 - 0.08 * sick * sick * sick, hash(vec2(band, tick))) * (1.0 - calm);
  uv.x += tear * (hash(vec2(band, tick + 0.5)) - 0.5) * 0.12;
  float split = 0.014 * sick * sick;
  vec3 col;
  if (split > 0.0005) {
    col = vec3(tunnel(uv + vec2(split, 0.0), sick).r, tunnel(uv, sick).g,
               tunnel(uv - vec2(split, 0.0), sick).b);
  } else {
    col = tunnel(uv, sick);
  }
  vec2 q = gl_FragCoord.xy / res;
  col *= 0.45 + 0.55 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.25);
  gl_FragColor = vec4(col, 1.0);
}`;
  // ---- The page ------------------------------------------------------------
  const STYLE = `
.synesthete-demo { margin: 1.5rem 0; }
.synesthete-demo .sd-screen { position: relative; aspect-ratio: 16 / 10; border-radius: var(--radius); overflow: hidden; background: black; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.08); }
.synesthete-demo canvas { display: block; width: 100%; height: 100%; }
.synesthete-demo .sd-hud { position: absolute; left: 14px; bottom: 14px; pointer-events: none; padding: 8px 12px; border-radius: 8px; background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: rgba(255, 255, 255, 0.9); line-height: 1.3; }
.synesthete-demo .sd-hud * { font-family: -apple-system, system-ui, sans-serif; }
.synesthete-demo .sd-stage { font-size: 1.1rem; font-weight: 700; letter-spacing: 0.05em; }
.synesthete-demo .sd-detail, .synesthete-demo .sd-last { font-size: 0.65rem; opacity: 0.75; }
.synesthete-demo .sd-last { opacity: 0; margin-top: 4px; }
.synesthete-demo .sd-missing { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; padding: 1rem; text-align: center; color: rgba(255, 255, 255, 0.8); }
.synesthete-demo .sd-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; margin-top: 0.75rem; }
.synesthete-demo .sd-quality { flex: 1 1 14rem; display: flex; align-items: center; gap: 0.5rem; margin: 0; color: var(--muted); font-variant-caps: all-small-caps; letter-spacing: 0.06em; }
.synesthete-demo .sd-quality input { flex: 1; min-width: 0; accent-color: var(--chocolate); }
.synesthete-demo .sd-changes { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.synesthete-demo button { padding: 0.2rem 0.8rem; border: 1px solid var(--field-border); border-radius: 6px; background: var(--paper-raised); color: var(--ink); font-size: 0.9rem; cursor: pointer; }
.synesthete-demo button:hover { border-color: var(--chocolate); }
.synesthete-demo button:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); border-color: var(--chocolate); }
.synesthete-demo button:disabled { cursor: progress; opacity: 0.6; }
.synesthete-demo .sd-sound[aria-pressed="true"] { border-color: var(--chocolate); color: var(--accent); }`;
  try {
    const sheet = new CSSStyleSheet();
    sheet.replaceSync(STYLE);
    document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
  } catch (e) {
    // Browsers too old for this show the demo unstyled.
  }
  function element(tag, className, text) {
    const e = document.createElement(tag);
    if (className) e.className = className;
    if (text) e.textContent = text;
    return e;
  }
  root.classList.add('synesthete-demo');
  const screen = root.appendChild(element('div', 'sd-screen'));
  const canvas = screen.appendChild(element('canvas'));
  const hud = screen.appendChild(element('div', 'sd-hud'));
  const stageLabel = hud.appendChild(element('div', 'sd-stage'));
  const detail = hud.appendChild(element('div', 'sd-detail'));
  const last = hud.appendChild(element('div', 'sd-last'));
  const controls = root.appendChild(element('div', 'sd-controls'));
  const sound = controls.appendChild(element('button', 'sd-sound', 'Play sound'));
  sound.type = 'button';
  sound.setAttribute('aria-pressed', 'false');
  const quality = controls.appendChild(element('label', 'sd-quality'));
  quality.appendChild(element('span', 'sd-end', 'cursed'));
  const slider = quality.appendChild(element('input'));
  slider.type = 'range';
  slider.min = 0;
  slider.max = 100;
  slider.value = Math.round(START * 100);
  slider.setAttribute('aria-label', 'Code quality');
  quality.appendChild(element('span', 'sd-end', 'beautiful'));
  const changes = controls.appendChild(element('div', 'sd-changes'));
  const better = changes.appendChild(element('button', 'sd-change', 'Make a good change'));
  const worse = changes.appendChild(element('button', 'sd-change', 'Make a bad change'));
  better.type = worse.type = 'button';
  // ---- The code base -------------------------------------------------------
  let health = START;
  // How much better or worse than the whole each file is, so the bands
  // don't all share one color
  const files = FILES.map(([name, lines], i) => ({
    name, lines, lean: 0.12 * Math.sin(i * 2.4 + 0.7), health, broken: false
  }));
  function stageOf(h) {
    const i = THRESHOLDS.findIndex(threshold => h >= threshold);
    return i < 0 ? STAGES.length - 1 : i;
  }
  function clamp(x) { return Math.min(1, Math.max(0, x)); }
  function update() {
    // Below "bad", the worst files stop parsing, more of them the worse it gets.
    const broken = health < 0.3 ? Math.ceil((0.3 - health) / 0.3 * 5) : 0;
    const worst = files.slice().sort((a, b) => a.lean - b.lean).slice(0, broken);
    files.forEach(f => {
      f.health = clamp(health + f.lean);
      f.broken = worst.includes(f);
    });
    const stage = stageOf(health);
    stageLabel.textContent = STAGES[stage];
    stageLabel.style.color = 'rgb(' + COLORS[stage].join(',') + ')';
    detail.textContent = health.toFixed(2) + ' · ' + files.length + ' files' +
      (broken ? ' · ' + broken + ' broken' : '');
    slider.setAttribute('aria-valuetext', STAGES[stage] + ', ' + health.toFixed(2));
    layout();
  }
  // ---- The tunnel ----------------------------------------------------------
  let segments = [];  // {name, start, end, color, broken}, around the tunnel
  const texelSegment = new Int32Array(MAP);
  const flashes = new Map();  // file name -> time it changed
  const pulses = [];  // {u, at, way}
  let shownHealth = health;  // glides toward health
  function now() { return performance.now() / 1000; }
  function layout() {
    const sorted = files.slice().sort((a, b) => a.name < b.name ? -1 : 1);
    const total = sorted.reduce((sum, f) => sum + f.lines, 0);
    let start = 0;
    segments = sorted.map(f => {
      const end = start + f.lines / total;
      const segment = {name: f.name, start, end, color: COLORS[stageOf(f.health)], broken: f.broken};
      start = end;
      return segment;
    });
    let s = 0;
    for (let i = 0; i < MAP; i++) {
      const u = (i + 0.5) / MAP;
      while (s < segments.length - 1 && u > segments[s].end) s++;
      texelSegment[i] = s;
    }
  }
  function changed(file, way) {
    const t = now();
    flashes.set(file.name, t);
    const segment = segments.find(s => s.name === file.name);
    pulses.push({u: (segment.start + segment.end) / 2, at: t, way});
    if (pulses.length > PULSES) pulses.shift();
    last.textContent = (way === 'up' ? '↗ ' : '↘ ') + file.name;
    last.style.transition = 'none';
    last.style.opacity = 1;
    requestAnimationFrame(() => requestAnimationFrame(() => {
      last.style.transition = 'opacity 3s';
      last.style.opacity = 0;
    }));
  }
  function compile(gl, type, source) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
    return shader;
  }
  function startTunnel() {
    const gl = canvas.getContext('webgl', {antialias: false});
    if (!gl) {
      screen.appendChild(element('p', 'sd-missing', 'Your browser can’t show the tunnel, but you can still listen.'));
      return;
    }
    const program = gl.createProgram();
    gl.attachShader(program, compile(gl, gl.VERTEX_SHADER,
      'attribute vec2 p; void main() { gl_Position = vec4(p, 0.0, 1.0); }'));
    gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
    gl.useProgram(program);
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const position = gl.getAttribLocation(program, 'p');
    gl.enableVertexAttribArray(position);
    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
    gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    const texels = new Uint8Array(MAP * 4);
    const u = name => gl.getUniformLocation(program, name);
    const uniforms = {res: u('res'), t: u('t'), health: u('health'), calm: u('calm'),
                      files: u('files'), pulses: u('pulses')};
    const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const pulseData = new Float32Array(PULSES * 4);
    // Only draw while the tunnel is on screen, to spare readers' batteries.
    let visible = false, running = false;
    new IntersectionObserver(entries => {
      visible = entries[0].isIntersecting;
      if (visible && !running) {
        running = true;
        requestAnimationFrame(frame);
      }
    }).observe(canvas);
    function frame() {
      if (!visible) {
        running = false;
        return;
      }
      const t = now();
      const scale = Math.min(window.devicePixelRatio || 1, 1.5);
      const width = Math.floor(canvas.clientWidth * scale), height = Math.floor(canvas.clientHeight * scale);
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }
      gl.viewport(0, 0, width, height);
      shownHealth += (health - shownHealth) * 0.04;
      for (let i = 0; i < MAP; i++) {  // each file's color, and how brightly it glows
        const segment = segments[texelSegment[i]];
        const o = i * 4;
        const edge = i > 0 && texelSegment[i - 1] !== texelSegment[i] &&
                     (segment.end - segment.start) * MAP > 3;
        const dim = edge ? 0.35 : 1;
        const since = t - (flashes.get(segment.name) || -100);
        const glow = since < FLASH ? 1 - since / FLASH : (segment.broken ? 0.2 + 0.15 * Math.sin(t * 2) : 0);
        texels[o] = segment.color[0] * dim;
        texels[o + 1] = segment.color[1] * dim;
        texels[o + 2] = segment.color[2] * dim;
        texels[o + 3] = Math.max(0, glow) * 255;
      }
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, MAP, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, texels);
      pulseData.fill(0);
      pulses.forEach((p, i) => {
        pulseData.set([p.u, p.at, 1, p.way === 'up' ? 1 : -1], i * 4);
      });
      gl.uniform2f(uniforms.res, width, height);
      gl.uniform1f(uniforms.t, t);
      gl.uniform1f(uniforms.health, shownHealth);
      gl.uniform1f(uniforms.calm, reducedMotion.matches ? 1 : 0);
      gl.uniform1i(uniforms.files, 0);
      gl.uniform4fv(uniforms.pulses, pulseData);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      requestAnimationFrame(frame);
    }
  }
  // ---- The music -----------------------------------------------------------
  let context = null, master = null, audio = null;  // audio: the decoded file, once loaded
  let playing = false, timer = null;
  let nextBar = 0, bar = 0, barStage = null;
  let accentTurn = 0;
  const sources = new Set();
  function load() {
    if (!audio) {
      audio = fetch(AUDIO)
        .then(response => response.arrayBuffer())
        .then(data => new Promise((resolve, reject) => context.decodeAudioData(data, resolve, reject)));
      audio.catch(() => { audio = null; });
    }
    return audio;
  }
  function play(buffer, clip, when) {
    const source = context.createBufferSource();
    source.buffer = buffer;
    source.connect(master);
    source.start(when, clip.at, clip.length);
    sources.add(source);
    source.onended = () => sources.delete(source);
  }
  // Bars play back to back, walking through the code, in whatever stage the
  // code is in when each one starts. A bar is queued just before it's due, so
  // the slider is heard at the next bar line.
  function schedule(buffer) {
    while (nextBar < context.currentTime + 0.2) {
      barStage = stageOf(health);
      const bars = CLIPS.bars[barStage];
      const clip = bars[bar % bars.length];
      play(buffer, clip, Math.max(nextBar, context.currentTime));
      nextBar += clip.period;
      bar++;
    }
  }
  async function startSound() {
    if (playing) return;
    playing = true;
    sound.textContent = 'Stop sound';
    sound.setAttribute('aria-pressed', 'true');
    if (!context) {
      context = new (window.AudioContext || window.webkitAudioContext)();
      master = context.createGain();
      master.connect(context.destination);
    }
    context.resume();
    sound.disabled = true;
    let buffer;
    try {
      buffer = await load();
    } catch (e) {
      playing = false;
      sound.textContent = 'Couldn’t load the sound';
      return;
    } finally {
      sound.disabled = false;
    }
    if (!playing) return;  // stopped while it loaded
    master.gain.cancelScheduledValues(context.currentTime);
    master.gain.setValueAtTime(1, context.currentTime);
    nextBar = context.currentTime + 0.05;
    schedule(buffer);
    timer = setInterval(() => schedule(buffer), 50);
  }
  function stopSound() {
    if (!playing) return;
    playing = false;
    sound.textContent = 'Play sound';
    sound.setAttribute('aria-pressed', 'false');
    clearInterval(timer);
    if (!context) return;
    const t = context.currentTime;
    master.gain.cancelScheduledValues(t);
    master.gain.setValueAtTime(master.gain.value, t);
    master.gain.linearRampToValueAtTime(0, t + 0.4);  // the room fades out rather than cutting off
    const fading = Array.from(sources);
    setTimeout(() => fading.forEach(source => source.stop()), 450);
  }
  // When the stage changes, the next bar starts right away, as in the plugin.
  function hurry() {
    if (!playing || !context || barStage === null || stageOf(health) === barStage) return;
    if (nextBar - context.currentTime > 0.3) {
      nextBar = context.currentTime + 0.05;
    }
  }
  async function accent(way) {
    const buffer = await load();
    const choices = CLIPS.accents[stageOf(health)][way];
    play(buffer, choices[accentTurn++ % choices.length], context.currentTime);
  }
  // ---- Controls ------------------------------------------------------------
  sound.addEventListener('click', () => playing ? stopSound() : startSound());
  // A change lands in a file, more likely a long one, as in real code: the
  // file lights up and the music plays an accent. A held key makes changes
  // no faster than the plugin plays accents.
  let lastHeard = 0;
  function hear(way) {
    const t = now();
    if (t - lastHeard < 0.15) return;
    lastHeard = t;
    const total = files.reduce((sum, f) => sum + f.lines, 0);
    let pick = Math.random() * total;
    const file = files.find(f => (pick -= f.lines) < 0) || files[0];
    changed(file, way);
    startSound().then(() => accent(way)).then(hurry).catch(() => {});  // the button says if it failed
  }
  function change(way) {
    health = clamp(health + (way === 'up' ? STEP : -STEP));
    slider.value = Math.round(health * 100);
    update();
    hear(way);
  }
  // Moving the slider with the keyboard makes a change with every step;
  // dragging it doesn't, or a drag would make dozens.
  let keyed = false;
  slider.addEventListener('keydown', () => { keyed = true; });
  slider.addEventListener('keyup', () => { keyed = false; });
  slider.addEventListener('pointerdown', () => { keyed = false; });
  slider.addEventListener('input', () => {
    const way = slider.value / 100 > health ? 'up' : 'down';
    health = slider.value / 100;
    update();
    if (keyed) {
      hear(way);
    } else {
      hurry();
    }
  });
  better.addEventListener('click', () => change('up'));
  worse.addEventListener('click', () => change('down'));
  // Leaving the page silences it, and coming back doesn't start it again.
  window.addEventListener('pagehide', stopSound);
  update();
  startTunnel();
});
</script>

<div id="synesthete-demo"></div>

You can now *listen* to your agents change your code in real time. You can *sense* whether the quality of your code base gets better or worse, without looking at a single line.

Get Synesthete at [github.com/dchacke/synesthete](https://github.com/dchacke/synesthete).
Playing ~