From b6d9af1bf09d8e5a2fbf5751c2cdd34df1adca82 Mon Sep 17 00:00:00 2001
From: Matt <59450879+mattppal@users.noreply.github.com>
Date: Thu, 11 Jun 2026 09:11:22 -0700
Subject: [PATCH 1/3] Add selectable train dispatch sounds with localStorage
persistence.
Let users pick between choo-choo and SF Muni chime sounds, and document how to add more in the README.
Co-authored-by: Cursor
---
README.md | 7 +++
index.html | 126 +++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 114 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index bac938e..35f737a 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,13 @@ open index.html
Edit `index.html`, then refresh the browser.
+### Train Sounds
+
+Train dispatch sounds are configured in the `TRAIN_SOUNDS` array in `index.html`.
+Add new sound files to `public/`, then add another object with an `id`, `label`,
+`src`, and `volume`. The selected sound is saved in the browser with
+`localStorage`, so it persists after refreshes.
+
## Project Structure
- `index.html` contains the UI, styling, and interaction logic.
diff --git a/index.html b/index.html
index ca20e5e..df1001a 100644
--- a/index.html
+++ b/index.html
@@ -134,22 +134,26 @@
will-change: transform, opacity;
}
- .mute-toggle {
+ .audio-controls {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .sound-select,
+ .mute-toggle {
display: inline-flex;
- width: 38px;
height: 38px;
align-items: center;
justify-content: center;
border: 1px solid var(--faint);
- border-radius: 999px;
background: var(--panel);
color: var(--ink);
cursor: pointer;
- font-size: 16px;
backdrop-filter: blur(14px);
transition:
border-color 150ms ease,
@@ -157,12 +161,40 @@
transform 150ms ease;
}
+ .sound-select {
+ max-width: min(44vw, 180px);
+ border-radius: 999px;
+ padding: 0 34px 0 14px;
+ appearance: none;
+ background-image:
+ linear-gradient(45deg, transparent 50%, var(--muted) 50%),
+ linear-gradient(135deg, var(--muted) 50%, transparent 50%);
+ background-position:
+ calc(100% - 17px) 16px,
+ calc(100% - 12px) 16px;
+ background-size:
+ 5px 5px,
+ 5px 5px;
+ background-repeat: no-repeat;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ }
+
+ .mute-toggle {
+ width: 38px;
+ border-radius: 999px;
+ font-size: 16px;
+ }
+
+ .sound-select:hover,
.mute-toggle:hover {
background: #fff;
border-color: #b8b8b8;
transform: translateY(-1px);
}
+ .sound-select:focus-visible,
.mute-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
@@ -273,15 +305,19 @@
-
+
+
+
+
+
0 trains dispatched
@@ -294,13 +330,33 @@
const PUFF_COUNT = 4;
const MIN_DURATION_MS = 4500;
const MAX_DURATION_MS = 7500;
+ const TRAIN_SOUNDS = [
+ {
+ id: "choo-choo",
+ label: "Choo choo",
+ src: "./public/choo-choo.mp3",
+ volume: 0.28,
+ },
+ {
+ id: "sf-muni",
+ label: "SF Muni chime",
+ src: "./public/sf-muni-inbound-train-chime.mp3",
+ volume: 0.55,
+ },
+ ];
const dispatcher = document.querySelector("#dispatcher");
const hero = document.querySelector("#hero");
const muteButton = document.querySelector("#mute");
+ const soundSelect = document.querySelector("#sound");
const counter = document.querySelector("#counter");
- const choo = new Audio("./public/choo-choo.mp3");
- choo.preload = "auto";
+ const sounds = new Map(
+ TRAIN_SOUNDS.map((sound) => {
+ const audio = new Audio(sound.src);
+ audio.preload = "auto";
+ return [sound.id, { ...sound, audio }];
+ }),
+ );
let count = 0;
let nextId = 1;
@@ -308,6 +364,9 @@
let lastLane = -1;
let lastDirection = "rtl";
let muted = localStorage.getItem("wtc:muted") === "1";
+ let selectedSoundId = sounds.has(localStorage.getItem("wtc:sound"))
+ ? localStorage.getItem("wtc:sound")
+ : TRAIN_SOUNDS[0].id;
function pick(items) {
return items[Math.floor(Math.random() * items.length)];
@@ -320,6 +379,16 @@
localStorage.setItem("wtc:muted", muted ? "1" : "0");
}
+ function syncSoundSelect() {
+ soundSelect.value = selectedSoundId;
+ localStorage.setItem("wtc:sound", selectedSoundId);
+ }
+
+ function isControlElement(element) {
+ return element instanceof Element
+ && Boolean(element.closest("button, select, input, textarea, a"));
+ }
+
function updateCounter() {
const noun = count === 1 ? "train" : "trains";
counter.textContent = `${count} ${noun} dispatched`;
@@ -327,10 +396,11 @@
count === 0 ? "welcome to conductor" : `welcome to conductor (${count})`;
}
- function playChoo() {
+ function playTrainSound() {
if (muted) return;
- const node = choo.cloneNode(true);
- node.volume = 0.28;
+ const sound = sounds.get(selectedSoundId) ?? sounds.get(TRAIN_SOUNDS[0].id);
+ const node = sound.audio.cloneNode(true);
+ node.volume = sound.volume;
node.play().catch(() => {});
}
@@ -390,7 +460,7 @@
count += 1;
hero.classList.add("dispatched");
updateCounter();
- playChoo();
+ playTrainSound();
for (let i = 0; i < PUFF_COUNT; i += 1) {
createSteam(train, (durationMs / (PUFF_COUNT + 1)) * (i + 1));
@@ -403,8 +473,18 @@
muted = !muted;
syncMuteButton();
});
+ soundSelect.addEventListener("click", (event) => {
+ event.stopPropagation();
+ });
+ soundSelect.addEventListener("change", (event) => {
+ event.stopPropagation();
+ selectedSoundId = event.currentTarget.value;
+ syncSoundSelect();
+ });
window.addEventListener("keydown", (event) => {
+ if (isControlElement(event.target)) return;
+
if (event.code === "Space" || event.code === "Enter") {
event.preventDefault();
dispatchTrain();
@@ -414,7 +494,15 @@
}
});
+ for (const sound of TRAIN_SOUNDS) {
+ const option = document.createElement("option");
+ option.value = sound.id;
+ option.textContent = sound.label;
+ soundSelect.append(option);
+ }
+
syncMuteButton();
+ syncSoundSelect();
updateCounter();
From 40e64c36096aa49523b9faea3ade52928907499e Mon Sep 17 00:00:00 2001
From: Matt <59450879+mattppal@users.noreply.github.com>
Date: Thu, 11 Jun 2026 10:19:53 -0700
Subject: [PATCH 2/3] Add large train dispatch counter
---
index.html | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 140 insertions(+), 8 deletions(-)
diff --git a/index.html b/index.html
index ca20e5e..77c30e2 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@