Add per-node scene music volume control

Author a music track and volume (0-100%) per story node; the runtime
scales it to 0-1 and applies it without restarting the track when only
the level changes. Adds migration 024, threads music_volume through the
repository/runtime, and gives the node inspector a volume slider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 21:02:16 +02:00
co-authored by Claude Opus 4.8
parent 80e5f548ac
commit 1b3ff1e78c
8 changed files with 36 additions and 22 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ export function App() {
// Scene music follows the current node (null inherits; a finished playthrough stops).
useEffect(() => {
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl)
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl, runtimeNode.musicVolume)
else if (playthrough?.status === 'finished') audio.setMusic(null)
}, [runtimeNode, playthrough])
+8 -5
View File
@@ -2,7 +2,7 @@
// 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 baseVolume = 0.5 // authored scene volume (0-1); mute overrides to 0
let ctx: AudioContext | null = null
function audioContext() {
@@ -43,13 +43,16 @@ function fade(target: number, done?: () => void) {
function startMusic() {
if (!music || !currentUrl || muted) return
const promise = music.play()
if (promise) promise.then(() => { pending = false; fade(MUSIC_VOLUME) }).catch(() => { pending = true })
if (promise) promise.then(() => { pending = false; fade(baseVolume) }).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
// The same url with a new volume just adjusts the level (no restart).
setMusic(url: string | null, volume?: number) {
if (!music) return
if (volume !== undefined) baseVolume = Math.max(0, Math.min(1, volume))
if (url === currentUrl) { if (volume !== undefined && currentUrl && !muted) fade(baseVolume); return }
currentUrl = url
if (!url) { fade(0, () => music.pause()); pending = false; return }
music.src = url
@@ -59,7 +62,7 @@ export const audio = {
toggleMute() {
muted = !muted
if (muted) fade(0)
else if (currentUrl) startMusic()
else if (currentUrl) { if (music && music.paused) startMusic(); else fade(baseVolume) }
return muted
},
isMuted() { return muted },
+6 -1
View File
@@ -4,7 +4,7 @@ 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; musicAssetId: 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; musicVolume: number; 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 }
@@ -182,6 +182,7 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
}) {
const [label, setLabel] = useState(node.label)
const [componentKey, setComponentKey] = useState(node.componentKey || '')
const [volume, setVolume] = useState(node.musicVolume)
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
return <div className="inspector-body">
@@ -204,6 +205,10 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
{audioAssets.map(asset => <option key={asset.id} value={asset.id}>{asset.originalName}</option>)}
</select>
</label>
{node.musicAssetId && <label className="ins-field"><span>Volume {volume}%</span>
<input type="range" min={0} max={100} value={volume} onChange={e => setVolume(Number(e.target.value))}
onPointerUp={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} onBlur={() => volume !== node.musicVolume && onPatch({ musicVolume: volume })} />
</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">
+1 -1
View File
@@ -2,7 +2,7 @@ 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; musicUrl?: 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; musicVolume?: number; 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 }