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>