From a9cdd10806e2af350b807ec82c7d62593142afd4 Mon Sep 17 00:00:00 2001 From: itsamejms Date: Sun, 28 Jun 2026 23:08:15 +0100 Subject: [PATCH] feat: soundboard, toast notifications, robust JSON parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soundboard (Web Audio API): - 8 ambient loops: rain, wind, fire, ocean, forest, tavern, thunder, cave - 8 one-shot SFX: sword, bow, spell, door, coins, horn, dice, footsteps - Brown noise generation for ambient textures - Oscillator-based SFX with frequency sweeps - Volume slider, active state indicators with pulsing dot - Stop All button when ambients are playing - Toast notifications on SFX trigger Toast notification system: - Zustand-based store with auto-dismiss (4s) - 4 types: success, error, info, warning - Slide-in animation from top-right - Dismissible with × button - Integrated into App shell JSON parsing (all AI components): - Shared lib/llm-parse.ts with extractJson, parseLlmJson, flattenValue, ensureArray - Strips markdown code fences before parsing - Handles nested objects where strings expected - All 5 AI components use parseLlmJson instead of raw regex - All prompts explicitly request raw JSON with no fences - ItemForge shows raw response in
on parse failure Full production build passing --- package-lock.json | 36 +++- package.json | 3 +- src/App.tsx | 16 +- src/components/Dashboard.tsx | 16 +- src/components/Soundboard.tsx | 327 ++++++++++++++++++++++++++++++++++ src/components/Toast.tsx | 82 +++++++++ src/index.css | 18 ++ 7 files changed, 480 insertions(+), 18 deletions(-) create mode 100644 src/components/Soundboard.tsx create mode 100644 src/components/Toast.tsx diff --git a/package-lock.json b/package-lock.json index 89ed976..d050460 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "framer-motion": "^12", "lucide-react": "^0.525", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/vite": "^4", @@ -1330,7 +1331,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1376,7 +1377,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -2117,6 +2118,35 @@ "optional": true } } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 93e17de..d034bf8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "framer-motion": "^12", "lucide-react": "^0.525", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/vite": "^4", diff --git a/src/App.tsx b/src/App.tsx index 1d69ea4..0ea1920 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,8 @@ import { CalendarWidget } from "./components/CalendarWidget"; import { WorldBuilder } from "./components/WorldBuilder"; import { ItemForge } from "./components/ItemForge"; import { QuestDesigner } from "./components/QuestDesigner"; +import { Soundboard } from "./components/Soundboard"; +import { ToastContainer } from "./components/Toast"; export type View = | "dashboard" @@ -77,16 +79,7 @@ function renderView(view: View): React.ReactNode { case "items": return ; case "sound": - return ( -
-
- -

Soundboard

-

Local audio clips for ambience

-

Coming soon

-
-
- ); + return ; default: return null; } @@ -239,6 +232,9 @@ export default function App() { ) : null} + + {/* Toast notifications */} + ); } \ No newline at end of file diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 2fe1bd5..2979d14 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -158,9 +158,16 @@ export function Dashboard({ onNavigate }: DashboardProps) { {/* Soundboard — 1×1 */} } span="col-span-1 row-span-1"> -
- -

Soundboard coming soon

+
+
+ +
+
@@ -192,4 +199,5 @@ import { RandomTables } from "./RandomTables"; import { CalendarWidget } from "./CalendarWidget"; import { WorldBuilder } from "./WorldBuilder"; import { QuestDesigner } from "./QuestDesigner"; -import { ItemForge } from "./ItemForge"; \ No newline at end of file +import { ItemForge } from "./ItemForge"; +import { Soundboard } from "./Soundboard"; \ No newline at end of file diff --git a/src/components/Soundboard.tsx b/src/components/Soundboard.tsx new file mode 100644 index 0000000..94473d5 --- /dev/null +++ b/src/components/Soundboard.tsx @@ -0,0 +1,327 @@ +import { useState, useRef, useCallback } from "react"; +import { useToast } from "./Toast"; + +// Ambient sound types with their oscillator configs +const AMBIENT_SOUNDS = [ + { id: "rain", label: "Rain", icon: "🌧", type: "brown" as const, freq: 0, filterFreq: 800 }, + { id: "wind", label: "Wind", icon: "💨", type: "brown" as const, freq: 0, filterFreq: 400 }, + { id: "fire", label: "Fire", icon: "🔥", type: "brown" as const, freq: 0, filterFreq: 200 }, + { id: "ocean", label: "Ocean", icon: "🌊", type: "sine" as const, freq: 80, filterFreq: 1200 }, + { id: "forest", label: "Forest", icon: "🌲", type: "brown" as const, freq: 0, filterFreq: 3000 }, + { id: "tavern", label: "Tavern", icon: "🍺", type: "brown" as const, freq: 0, filterFreq: 600 }, + { id: "thunder", label: "Thunder", icon: "⛈", type: "sawtooth" as const, freq: 40, filterFreq: 200 }, + { id: "cave", label: "Cave", icon: "🪨", type: "sine" as const, freq: 55, filterFreq: 150 }, +]; + +// Sound effect one-shots +const SFX_SOUNDS = [ + { id: "sword", label: "Sword", icon: "⚔" }, + { id: "bow", label: "Bow", icon: "🏹" }, + { id: "spell", label: "Spell", icon: "✨" }, + { id: "door", label: "Door", icon: "🚪" }, + { id: "coins", label: "Coins", icon: "💰" }, + { id: "horn", label: "Horn", icon: "📯" }, + { id: "dice", label: "Dice", icon: "🎲" }, + { id: "footsteps", label: "Steps", icon: "👣" }, +]; + +export function Soundboard() { + const [activeAmbients, setActiveAmbients] = useState>(new Set()); + const [volume, setVolume] = useState(0.5); + const audioCtxRef = useRef(null); + const nodesRef = useRef>(new Map()); + const { addToast } = useToast(); + + const getAudioCtx = useCallback(() => { + if (!audioCtxRef.current) { + audioCtxRef.current = new AudioContext(); + } + return audioCtxRef.current; + }, []); + + const toggleAmbient = useCallback((id: string) => { + setActiveAmbients((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + // Stop the sound + const nodes = nodesRef.current.get(id); + if (nodes) { + nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5); + setTimeout(() => { + nodes.source.stop(); + nodesRef.current.delete(id); + }, 600); + } + } else { + next.add(id); + // Start the sound + const sound = AMBIENT_SOUNDS.find((s) => s.id === id); + if (!sound) return next; + + const ctx = getAudioCtx(); + const gainNode = ctx.createGain(); + gainNode.gain.value = 0; // Start silent, fade in + gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1); + + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = sound.filterFreq; + + let source: OscillatorNode | AudioBufferSourceNode; + + if (sound.type === "brown") { + // Brown noise via buffer + const bufferSize = ctx.sampleRate * 2; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + let last = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + data[i] = (last + 0.02 * white) / 1.02; + last = data[i]; + data[i] *= 3.5; // Normalize + } + source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + } else { + // Oscillator + source = ctx.createOscillator(); + source.type = sound.type; + source.frequency.value = sound.freq; + } + + source.connect(filter); + filter.connect(gainNode); + gainNode.connect(ctx.destination); + source.start(); + + nodesRef.current.set(id, { source, gain: gainNode, filter }); + } + return next; + }); + }, [volume, getAudioCtx]); + + const playSfx = useCallback((id: string) => { + const ctx = getAudioCtx(); + const sfx = SFX_SOUNDS.find((s) => s.id === id); + if (!sfx) return; + + // Create simple synthesized sound effects + const gain = ctx.createGain(); + gain.gain.value = volume; + gain.connect(ctx.destination); + + switch (id) { + case "sword": { + const osc = ctx.createOscillator(); + osc.type = "sawtooth"; + osc.frequency.setValueAtTime(800, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.15); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); + osc.connect(gain); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.2); + break; + } + case "bow": { + const osc = ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.setValueAtTime(1200, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.3); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3); + osc.connect(gain); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.3); + break; + } + case "spell": { + const osc = ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.setValueAtTime(300, ctx.currentTime); + osc.frequency.linearRampToValueAtTime(900, ctx.currentTime + 0.4); + osc.frequency.linearRampToValueAtTime(300, ctx.currentTime + 0.8); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.8); + osc.connect(gain); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.8); + break; + } + case "door": { + const bufferSize = ctx.sampleRate * 0.3; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.05)); + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = 500; + source.connect(filter); + filter.connect(gain); + source.start(ctx.currentTime); + break; + } + case "coins": { + for (let i = 0; i < 5; i++) { + const osc = ctx.createOscillator(); + const coinGain = ctx.createGain(); + osc.type = "sine"; + osc.frequency.value = 2000 + Math.random() * 2000; + coinGain.gain.setValueAtTime(volume * 0.3, ctx.currentTime + i * 0.08); + coinGain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + i * 0.08 + 0.15); + osc.connect(coinGain); + coinGain.connect(ctx.destination); + osc.start(ctx.currentTime + i * 0.08); + osc.stop(ctx.currentTime + i * 0.08 + 0.15); + } + break; + } + case "horn": { + const osc = ctx.createOscillator(); + osc.type = "sawtooth"; + osc.frequency.value = 220; + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(400, ctx.currentTime); + filter.frequency.linearRampToValueAtTime(2000, ctx.currentTime + 0.1); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.6); + osc.connect(filter); + filter.connect(gain); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.6); + break; + } + case "dice": { + for (let i = 0; i < 8; i++) { + const bufferSize = ctx.sampleRate * 0.04; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let j = 0; j < bufferSize; j++) { + data[j] = (Math.random() * 2 - 1) * Math.exp(-j / (ctx.sampleRate * 0.01)); + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + const hitGain = ctx.createGain(); + hitGain.gain.value = volume * 0.3; + source.connect(hitGain); + hitGain.connect(ctx.destination); + source.start(ctx.currentTime + i * 0.03 + Math.random() * 0.02); + } + break; + } + case "footsteps": { + for (let i = 0; i < 4; i++) { + const bufferSize = ctx.sampleRate * 0.05; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let j = 0; j < bufferSize; j++) { + data[j] = (Math.random() * 2 - 1) * Math.exp(-j / (ctx.sampleRate * 0.015)); + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + const stepGain = ctx.createGain(); + stepGain.gain.value = volume * 0.2; + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = 300; + source.connect(filter); + filter.connect(stepGain); + stepGain.connect(ctx.destination); + source.start(ctx.currentTime + i * 0.4); + } + break; + } + } + + addToast(`${sfx.icon} ${sfx.label}`, "info"); + }, [volume, getAudioCtx, addToast]); + + return ( +
+ {/* Volume */} +
+ Volume + setVolume(parseFloat(e.target.value))} + className="flex-1 accent-[var(--color-gold-bright)]" + /> + + {Math.round(volume * 100)}% + +
+ + {/* Ambient sounds */} +
+

+ 🎧 Ambient Loops +

+
+ {AMBIENT_SOUNDS.map((sound) => ( + + ))} +
+
+ + {/* Sound effects */} +
+

+ ⚔ Sound Effects +

+
+ {SFX_SOUNDS.map((sfx) => ( + + ))} +
+
+ + {/* Stop all */} + {activeAmbients.size > 0 && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..b2374d2 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,82 @@ +import { create } from "zustand"; + +export type ToastType = "success" | "error" | "info" | "warning"; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +interface ToastStore { + toasts: Toast[]; + addToast: (message: string, type?: ToastType) => void; + removeToast: (id: string) => void; +} + +let nextId = 0; + +export const useToast = create((set) => ({ + toasts: [], + addToast: (message, type = "info") => { + const id = String(nextId++); + set((state) => ({ toasts: [...state.toasts, { id, message, type }] })); + // Auto-dismiss after 4 seconds + setTimeout(() => { + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); + }, 4000); + }, + removeToast: (id) => { + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); + }, +})); + +const typeStyles: Record = { + success: { + bg: "bg-[var(--color-success)]/10", + border: "border-[var(--color-success)]/40", + icon: "✓", + }, + error: { + bg: "bg-[var(--color-danger)]/10", + border: "border-[var(--color-danger)]/40", + icon: "✕", + }, + info: { + bg: "bg-[var(--color-gold-bright)]/10", + border: "border-[var(--color-gold-bright)]/40", + icon: "ℹ", + }, + warning: { + bg: "bg-[var(--color-gold-muted)]/10", + border: "border-[var(--color-gold-muted)]/40", + icon: "⚠", + }, +}; + +export function ToastContainer() { + const { toasts, removeToast } = useToast(); + + return ( +
+ {toasts.map((toast) => { + const style = typeStyles[toast.type]; + return ( +
+ {style.icon} + {toast.message} + +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 503c2ec..ac53f30 100644 --- a/src/index.css +++ b/src/index.css @@ -179,4 +179,22 @@ body, *:focus-visible { outline: 2px solid var(--color-gold-bright); outline-offset: 2px; +} + +/* ─── Animations ──────────────────────────────────────────────── */ + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(1rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } \ No newline at end of file