diff --git a/components/ResultView.tsx b/components/ResultView.tsx
index c2aeaa6..078ed12 100644
--- a/components/ResultView.tsx
+++ b/components/ResultView.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { ArrowLeft } from "lucide-react";
+import { ArrowLeft, Volume2, VolumeX } from "lucide-react";
import type { Card } from "@/lib/scoring/types";
import PlayerCard from "./PlayerCard";
import StoryFrame from "./StoryFrame";
@@ -19,6 +19,7 @@ import DistributionPanel from "./DistributionPanel";
import { confettiPalette, resolveResultTheme } from "./finishTheme";
import { useReveal } from "@/hooks/useReveal";
import { burstConfetti } from "@/lib/confetti";
+import { isMuted, playRevealChime, setMuted } from "@/lib/sound";
const HowItWorksModal = dynamic(() => import("./HowItWorksModal"), { ssr: false });
@@ -70,6 +71,26 @@ export default function ResultView({
if (phase === "burst") burstConfetti(confettiPalette(card));
}, [phase, card]);
+ // Reveal chime: a small "pop" on ignite for common tiers, a brighter arpeggio
+ // on burst for rare ones (same tier split as the confetti above).
+ useEffect(() => {
+ if (phase === "ignite") playRevealChime(card.finish, false);
+ if (phase === "burst") playRevealChime(card.finish, true);
+ }, [phase, card]);
+
+ const [muted, setMutedState] = useState(false);
+ useEffect(() => {
+ const t = setTimeout(() => setMutedState(isMuted()), 0);
+ return () => clearTimeout(t);
+ }, []);
+ const toggleMuted = () => {
+ setMutedState((prev) => {
+ const next = !prev;
+ setMuted(next);
+ return next;
+ });
+ };
+
const ignited = phase === "ignite" || phase === "burst" || phase === "freeze";
return (
@@ -125,6 +146,15 @@ export default function ResultView({
>
how it works ↗
+
diff --git a/lib/sound.ts b/lib/sound.ts
new file mode 100644
index 0000000..81f4253
--- /dev/null
+++ b/lib/sound.ts
@@ -0,0 +1,73 @@
+import type { Finish } from "@/lib/scoring/types";
+
+// Synthesized reveal chimes — dependency-free, same philosophy as lib/confetti.ts
+// (no bundled audio assets, no audio library). A single shared AudioContext is
+// reused across calls; oscillator nodes are scheduled and torn down per chime.
+
+const MUTE_KEY = "gitfut:muted";
+
+let ctx: AudioContext | null = null;
+
+function getContext(): AudioContext | null {
+ if (typeof window === "undefined") return null;
+ const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!Ctor) return null;
+ try {
+ if (!ctx) ctx = new Ctor();
+ if (ctx.state === "suspended") void ctx.resume();
+ return ctx;
+ } catch {
+ return null;
+ }
+}
+
+export function isMuted(): boolean {
+ try {
+ return localStorage.getItem(MUTE_KEY) === "1";
+ } catch {
+ return false;
+ }
+}
+
+export function setMuted(muted: boolean): void {
+ try {
+ localStorage.setItem(MUTE_KEY, muted ? "1" : "0");
+ } catch {}
+}
+
+// Plays one note: a short envelope on a single oscillator, starting `delay`
+// seconds from now.
+function playNote(audio: AudioContext, freq: number, delay: number, duration: number, type: OscillatorType, peak: number): void {
+ const osc = audio.createOscillator();
+ const gain = audio.createGain();
+ osc.type = type;
+ osc.frequency.value = freq;
+ const t0 = audio.currentTime + delay;
+ gain.gain.setValueAtTime(0, t0);
+ gain.gain.linearRampToValueAtTime(peak, t0 + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
+ osc.connect(gain);
+ gain.connect(audio.destination);
+ osc.start(t0);
+ osc.stop(t0 + duration + 0.02);
+}
+
+// Rare tiers (totw/toty/icon/founder) get a brighter ascending arpeggio; every
+// other tier gets a single short "pop" — see lib/reveal.ts for which finishes
+// reach the "burst" phase.
+export function playRevealChime(finish: Finish, burst: boolean): void {
+ if (isMuted()) return;
+ const audio = getContext();
+ if (!audio) return;
+
+ try {
+ if (burst) {
+ const notes = [523.25, 659.25, 783.99]; // C5, E5, G5
+ notes.forEach((freq, i) => playNote(audio, freq, i * 0.09, 0.35, "triangle", 0.09));
+ } else {
+ playNote(audio, 440, 0, 0.15, "triangle", 0.06);
+ }
+ } catch {
+ // Autoplay/policy restrictions or a torn-down context — never break the reveal.
+ }
+}
diff --git a/tests/sound.test.ts b/tests/sound.test.ts
new file mode 100644
index 0000000..f919e24
--- /dev/null
+++ b/tests/sound.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { isMuted, playRevealChime, setMuted } from "@/lib/sound";
+
+// The test environment is plain node (see vitest.config.ts) — no DOM, so no
+// global `localStorage`. A minimal in-memory stand-in is enough to exercise
+// the isMuted/setMuted read-write path the same way a browser would.
+class MemoryStorage implements Storage {
+ private store = new Map();
+ get length() {
+ return this.store.size;
+ }
+ clear(): void {
+ this.store.clear();
+ }
+ getItem(key: string): string | null {
+ return this.store.has(key) ? this.store.get(key)! : null;
+ }
+ key(index: number): string | null {
+ return Array.from(this.store.keys())[index] ?? null;
+ }
+ removeItem(key: string): void {
+ this.store.delete(key);
+ }
+ setItem(key: string, value: string): void {
+ this.store.set(key, value);
+ }
+}
+globalThis.localStorage = new MemoryStorage();
+
+describe("mute preference", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("defaults to unmuted", () => {
+ expect(isMuted()).toBe(false);
+ });
+
+ it("round-trips through setMuted/isMuted", () => {
+ setMuted(true);
+ expect(isMuted()).toBe(true);
+ setMuted(false);
+ expect(isMuted()).toBe(false);
+ });
+});
+
+describe("playRevealChime", () => {
+ it("does not throw without a browser AudioContext (node test environment)", () => {
+ expect(() => playRevealChime("bronze", false)).not.toThrow();
+ expect(() => playRevealChime("icon", true)).not.toThrow();
+ });
+
+ it("no-ops silently when muted", () => {
+ setMuted(true);
+ expect(() => playRevealChime("toty", true)).not.toThrow();
+ });
+});