Sound: per-node scene music + synthesized SFX (zero-dep)

- migration 023: story_nodes.music_asset_id (references the asset store).
- src/audio.ts: a tiny audio manager — one looping scene-music track (fade,
  dedup, gesture-primed autoplay) and synthesized one-shot SFX; global mute.
- Runtime: RuntimeNode.musicUrl; music follows the current node (null inherits,
  a finished playthrough stops). SFX blips on dialogue advance/choice (not in the
  editor preview). Floating mute toggle during play.
- Graph inspector: a "Scene music" picker sourced from uploaded audio assets.

No external library (we did not adopt react-winamp — it is a React 16 CRA app,
not an installable package). Bundle grew ~2 KB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:51:44 +02:00
co-authored by Claude Opus 4.8
parent 0b1e5e7fc7
commit dafc70b047
9 changed files with 135 additions and 22 deletions
+13 -2
View File
@@ -3,6 +3,7 @@ import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText,
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
@@ -66,6 +67,7 @@ export function App() {
const [splashBusy, setSplashBusy] = useState(false)
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
const [muted, setMuted] = useState(audio.isMuted())
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -151,6 +153,12 @@ export function App() {
} catch { setStatus('COULD NOT ADVANCE') }
}, [playthrough, applyState])
// Scene music follows the current node (null inherits; a finished playthrough stops).
useEffect(() => {
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl)
else if (playthrough?.status === 'finished') audio.setMusic(null)
}, [runtimeNode, playthrough])
useEffect(() => {
if (!adminMenuOpen) return
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
@@ -383,11 +391,13 @@ export function App() {
}
}
const audioToggle = playthrough ? <button className={`audio-toggle${muted ? ' muted' : ''}`} title={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted(audio.toggleMute())}></button> : null
if (adminRoute) return <AdminPanel />
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
if (runtimeNode?.kind === 'cutscene') return <CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} />
if (runtimeNode?.kind === 'dialogue') return <DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} />
if (runtimeNode?.kind === 'cutscene') return <>{audioToggle}<CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} /></>
if (runtimeNode?.kind === 'dialogue') return <>{audioToggle}<DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} /></>
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
@@ -411,6 +421,7 @@ export function App() {
})
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
return <main className="desktop">
{audioToggle}
<header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
+76
View File
@@ -0,0 +1,76 @@
// Lightweight game audio: one looping "scene music" track plus synthesized one-shot
// SFX. Zero dependencies. Browsers block autoplay until a user gesture, so music/SFX
// are primed on the first pointer interaction.
type Sfx = 'advance' | 'choice' | 'sting'
const MUSIC_VOLUME = 0.5
let ctx: AudioContext | null = null
function audioContext() {
if (!ctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) ctx = new AC() }
return ctx
}
const music = typeof Audio !== 'undefined' ? new Audio() : null
if (music) music.loop = true
let currentUrl: string | null = null
let muted = false
let pending = false
let fadeTimer: number | undefined
function fade(target: number, done?: () => void) {
if (!music) return
window.clearInterval(fadeTimer)
const step = (target - music.volume) / 12 || (target > music.volume ? 0.1 : -0.1)
fadeTimer = window.setInterval(() => {
if (!music) return
const next = music.volume + step
if ((step >= 0 && next >= target) || (step <= 0 && next <= target)) { music.volume = target; window.clearInterval(fadeTimer); done?.() }
else music.volume = Math.max(0, Math.min(1, next))
}, 40)
}
function startMusic() {
if (!music || !currentUrl || muted) return
const promise = music.play()
if (promise) promise.then(() => { pending = false; fade(MUSIC_VOLUME) }).catch(() => { pending = true })
}
export const audio = {
// A null url inherits whatever is already playing; a new url crossfades to it.
setMusic(url: string | null) {
if (!music || url === currentUrl) return
currentUrl = url
if (!url) { fade(0, () => music.pause()); pending = false; return }
music.src = url
music.volume = 0
startMusic()
},
toggleMute() {
muted = !muted
if (muted) fade(0)
else if (currentUrl) startMusic()
return muted
},
isMuted() { return muted },
sfx(kind: Sfx) {
if (muted) return
const context = audioContext()
if (!context || context.state !== 'running') return
const osc = context.createOscillator(), gain = context.createGain()
osc.type = 'sine'
osc.frequency.value = kind === 'choice' ? 540 : kind === 'sting' ? 300 : 400
const now = context.currentTime
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(0.07, now + 0.008)
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.12)
osc.connect(gain).connect(context.destination)
osc.start(now); osc.stop(now + 0.13)
},
// Resume the context and retry pending music on a user gesture.
resume() {
void audioContext()?.resume?.()
if (pending) startMusic()
},
}
if (typeof window !== 'undefined') window.addEventListener('pointerdown', () => audio.resume())
+17 -5
View File
@@ -4,7 +4,8 @@ import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; terminals: Terminal[] }
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; terminals: Terminal[] }
type AudioAsset = { id: string; originalName: string; mimeType: string }
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
@@ -26,6 +27,7 @@ async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { mysteryId: string; title: string; onClose: () => void; setStatus: (message: string) => void }) {
const [graph, setGraph] = useState<Graph | null>(null)
const [templates, setTemplates] = useState<LevelTemplate[]>([])
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
@@ -37,7 +39,11 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
try { setGraph(await api<Graph>(`/api/admin/mysteries/${mysteryId}/graph`, 'GET')) }
catch (error) { setStatus(String((error as Error).message || error)) }
}, [mysteryId, setStatus])
useEffect(() => { void reload(); api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {}) }, [reload])
useEffect(() => {
void reload()
api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {})
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).catch(() => {})
}, [reload])
const centerInBoard = () => {
const rect = canvasRef.current?.getBoundingClientRect()
@@ -149,7 +155,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
</div>
{selected && <aside className="graph-inspector">
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates}
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets}
onEditUtterances={() => setUtterancesNode(selected)}
onPatch={body => patchNode(selected.id, body)}
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
@@ -169,8 +175,8 @@ function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
return ''
}
function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
node: StoryNode; graph: Graph; templates: LevelTemplate[]
function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
}) {
@@ -192,6 +198,12 @@ function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete,
</label>}
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances </button>}
<label className="ins-field"><span>Scene music</span>
<select value={node.musicAssetId || ''} onChange={e => onPatch({ musicAssetId: e.target.value || null })}>
<option value=""> none / inherit </option>
{audioAssets.map(asset => <option key={asset.id} value={asset.id}>{asset.originalName}</option>)}
</select>
</label>
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
+4 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio'
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -84,6 +85,7 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
}, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps
const pick = (choice: RuntimeUtterance) => {
if (!inline) audio.sfx('choice')
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
else onExit(choice.terminalKey ?? undefined)
}
@@ -93,6 +95,7 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
if (!done) { setCharCount(fullText.length); return }
if (children.length === 0) { onExit(current.terminalKey ?? undefined); return }
if (options.length > 0) return // a branch — wait for a choice
if (!inline) audio.sfx('advance')
setCurrentId(children[0].id) // linear next line
}
useEffect(() => {
+5
View File
@@ -587,3 +587,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.dialogue-preview-empty { display: grid; place-items: center; height: 100%; color: #5f7b73; font: 9px IBM Plex Mono; }
.graph-node-preview { position: absolute; z-index: 6; }
.utterance-preview-dock { position: absolute; left: 16px; bottom: 16px; z-index: 6; }
/* Audio mute toggle (floats over cutscene/dialogue/board) */
.audio-toggle { position: fixed; top: 14px; right: 14px; z-index: 300; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3c5a52; background: #0a211de6; color: #d58a46; font-size: 16px; line-height: 1; cursor: pointer; }
.audio-toggle:hover { border-color: #6f8f85; color: #e7b57e; }
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }