feat: soundboard, toast notifications, robust JSON parsing

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 <details> on parse failure

Full production build passing
This commit is contained in:
itsamejms
2026-06-28 23:08:15 +01:00
parent b23ab61841
commit a9cdd10806
7 changed files with 480 additions and 18 deletions
+33 -3
View File
@@ -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
}
}
}
}
}
+2 -1
View File
@@ -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",
+6 -10
View File
@@ -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 <ItemForge />;
case "sound":
return (
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)]">
<div className="text-center">
<Volume2 size={64} className="mx-auto mb-4 opacity-30" />
<p className="text-lg font-heading">Soundboard</p>
<p className="text-sm mt-2">Local audio clips for ambience</p>
<p className="text-xs mt-1 opacity-60">Coming soon</p>
</div>
</div>
);
return <Soundboard />;
default:
return null;
}
@@ -239,6 +232,9 @@ export default function App() {
) : null}
</main>
</div>
{/* Toast notifications */}
<ToastContainer />
</div>
);
}
+12 -4
View File
@@ -158,9 +158,16 @@ export function Dashboard({ onNavigate }: DashboardProps) {
{/* Soundboard — 1×1 */}
<BentoCard title="Soundboard" icon={<Volume2 size={16} />} span="col-span-1 row-span-1">
<div className="flex flex-col items-center justify-center h-full text-[var(--color-text-dim)] text-sm gap-2">
<Volume2 size={32} className="opacity-30" />
<p>Soundboard coming soon</p>
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<Soundboard />
</div>
<button
onClick={() => onNavigate("sound")}
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
>
<Maximize2 size={12} /> Expand
</button>
</div>
</BentoCard>
@@ -192,4 +199,5 @@ import { RandomTables } from "./RandomTables";
import { CalendarWidget } from "./CalendarWidget";
import { WorldBuilder } from "./WorldBuilder";
import { QuestDesigner } from "./QuestDesigner";
import { ItemForge } from "./ItemForge";
import { ItemForge } from "./ItemForge";
import { Soundboard } from "./Soundboard";
+327
View File
@@ -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<Set<string>>(new Set());
const [volume, setVolume] = useState(0.5);
const audioCtxRef = useRef<AudioContext | null>(null);
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(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 (
<div className="flex flex-col gap-4">
{/* Volume */}
<div className="flex items-center gap-3">
<span className="text-[var(--color-text-secondary)] text-xs font-medium w-16">Volume</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="flex-1 accent-[var(--color-gold-bright)]"
/>
<span className="text-[var(--color-text-dim)] text-xs font-mono w-8 text-right">
{Math.round(volume * 100)}%
</span>
</div>
{/* Ambient sounds */}
<div>
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
🎧 Ambient Loops
</h3>
<div className="grid grid-cols-4 gap-2">
{AMBIENT_SOUNDS.map((sound) => (
<button
key={sound.id}
onClick={() => toggleAmbient(sound.id)}
className={`rounded-xl p-3 flex flex-col items-center gap-1.5 cursor-pointer transition-all ${
activeAmbients.has(sound.id)
? "glass-card-active scale-[1.02]"
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] hover:scale-[1.02]"
}`}
>
<span className="text-2xl">{sound.icon}</span>
<span className="text-[10px] text-[var(--color-text-secondary)]">{sound.label}</span>
{activeAmbients.has(sound.id) && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse" />
)}
</button>
))}
</div>
</div>
{/* Sound effects */}
<div>
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
Sound Effects
</h3>
<div className="grid grid-cols-4 gap-2">
{SFX_SOUNDS.map((sfx) => (
<button
key={sfx.id}
onClick={() => playSfx(sfx.id)}
className="rounded-xl p-3 flex flex-col items-center gap-1.5 bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] hover:scale-[1.02] transition-all cursor-pointer active:scale-95"
>
<span className="text-2xl">{sfx.icon}</span>
<span className="text-[10px] text-[var(--color-text-secondary)]">{sfx.label}</span>
</button>
))}
</div>
</div>
{/* Stop all */}
{activeAmbients.size > 0 && (
<button
onClick={() => {
nodesRef.current.forEach((nodes) => {
try {
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
} catch {}
});
nodesRef.current.clear();
setActiveAmbients(new Set());
}}
className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer"
>
Stop All
</button>
)}
</div>
);
}
+82
View File
@@ -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<ToastStore>((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<ToastType, { bg: string; border: string; icon: string }> = {
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 (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => {
const style = typeStyles[toast.type];
return (
<div
key={toast.id}
className={`pointer-events-auto glass-card px-4 py-3 flex items-center gap-2 text-sm ${style.bg} border ${style.border} animate-[slideIn_0.2s_ease-out]`}
>
<span className="text-base">{style.icon}</span>
<span className="text-[var(--color-text-primary)]">{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
className="ml-2 text-[var(--color-text-dim)] hover:text-[var(--color-text-primary)] cursor-pointer"
>
×
</button>
</div>
);
})}
</div>
);
}
+18
View File
@@ -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; }
}