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
@@ -0,0 +1,4 @@
-- Per-node scene-music volume (0-100%). Applied whenever a node sets a track;
-- selecting the same track on a later node with a different volume just adjusts
-- the level without restarting the music.
ALTER TABLE osint.story_nodes ADD COLUMN music_volume SMALLINT NOT NULL DEFAULT 100 CHECK (music_volume BETWEEN 0 AND 100);
+3 -3
View File
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = [] const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(23) expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(24)
const client = new Client({ connectionString: testDatabaseUrl }) const client = new Client({ connectionString: testDatabaseUrl })
await client.connect() await client.connect()
@@ -49,7 +49,7 @@ suite('PostgreSQL migrations', () => {
])) ]))
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue'])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('23') expect(ledger.rows[0].count).toBe('24')
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset'])) expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`) const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
@@ -58,7 +58,7 @@ suite('PostgreSQL migrations', () => {
const secondRun: string[] = [] const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(23) expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(24)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
}) })
}) })
+7 -6
View File
@@ -16,7 +16,7 @@ export type RuntimeUtterance = {
} }
export type RuntimeNode = { export type RuntimeNode = {
id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
utterances?: RuntimeUtterance[]; rootId?: string | null utterances?: RuntimeUtterance[]; rootId?: string | null
} }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -60,7 +60,7 @@ export interface NarrativeRepository {
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null> deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
} }
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null } type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number }
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository { export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
// ---- Runtime: walking the story graph ------------------------------------- // ---- Runtime: walking the story graph -------------------------------------
@@ -97,12 +97,13 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
} }
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> { async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0] const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
if (!node) return null if (!node) return null
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl } const musicVolume = node.music_volume / 100
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl } if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, ...(await resolveDialogueGraph(node.id)) } if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
return null // gates are auto-resolved during advance and never surfaced return null // gates are auto-resolved during advance and never surfaced
} }
+6 -5
View File
@@ -13,7 +13,7 @@ export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'll
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
export type StoryNodeDto = { export type StoryNodeDto = {
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
terminals: TerminalDto[] terminals: TerminalDto[]
} }
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] } export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
@@ -37,7 +37,7 @@ const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]>
export interface StoryGraphRepository { export interface StoryGraphRepository {
getGraph(mysteryId: string): Promise<StoryGraphDto | null> getGraph(mysteryId: string): Promise<StoryGraphDto | null>
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null> createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null }>): Promise<StoryNodeDto | null> updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number }>): Promise<StoryNodeDto | null>
deleteNode(nodeId: string): Promise<boolean> deleteNode(nodeId: string): Promise<boolean>
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null> addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }> updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }>
@@ -61,8 +61,8 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId]) const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
if (!mystery.rows[0]) return null if (!mystery.rows[0]) return null
const [nodes, terminals] = await Promise.all([ const [nodes, terminals] = await Promise.all([
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null }>( pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number }>(
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]), 'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>( pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>(
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t `SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]), JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
@@ -77,7 +77,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
mysteryId, entryNodeId: mystery.rows[0].entry_node_id, mysteryId, entryNodeId: mystery.rows[0].entry_node_id,
nodes: nodes.rows.map(row => ({ nodes: nodes.rows.map(row => ({
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances, id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
terminals: byNode.get(row.id) || [], terminals: byNode.get(row.id) || [],
})), })),
} }
@@ -118,6 +118,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
if (input.componentKey !== undefined) set('component_key', input.componentKey || null) if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null) if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null) if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values) if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
const graph = await loadGraph(mysteryId) const graph = await loadGraph(mysteryId)
return graph?.nodes.find(node => node.id === nodeId) ?? null return graph?.nodes.find(node => node.id === nodeId) ?? null
+1 -1
View File
@@ -155,7 +155,7 @@ export function App() {
// Scene music follows the current node (null inherits; a finished playthrough stops). // Scene music follows the current node (null inherits; a finished playthrough stops).
useEffect(() => { 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) else if (playthrough?.status === 'finished') audio.setMusic(null)
}, [runtimeNode, playthrough]) }, [runtimeNode, playthrough])
+8 -5
View File
@@ -2,7 +2,7 @@
// SFX. Zero dependencies. Browsers block autoplay until a user gesture, so music/SFX // SFX. Zero dependencies. Browsers block autoplay until a user gesture, so music/SFX
// are primed on the first pointer interaction. // are primed on the first pointer interaction.
type Sfx = 'advance' | 'choice' | 'sting' 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 let ctx: AudioContext | null = null
function audioContext() { function audioContext() {
@@ -43,13 +43,16 @@ function fade(target: number, done?: () => void) {
function startMusic() { function startMusic() {
if (!music || !currentUrl || muted) return if (!music || !currentUrl || muted) return
const promise = music.play() 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 = { export const audio = {
// A null url inherits whatever is already playing; a new url crossfades to it. // A null url inherits whatever is already playing; a new url crossfades to it.
setMusic(url: string | null) { // The same url with a new volume just adjusts the level (no restart).
if (!music || url === currentUrl) return 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 currentUrl = url
if (!url) { fade(0, () => music.pause()); pending = false; return } if (!url) { fade(0, () => music.pause()); pending = false; return }
music.src = url music.src = url
@@ -59,7 +62,7 @@ export const audio = {
toggleMute() { toggleMute() {
muted = !muted muted = !muted
if (muted) fade(0) if (muted) fade(0)
else if (currentUrl) startMusic() else if (currentUrl) { if (music && music.paused) startMusic(); else fade(baseVolume) }
return muted return muted
}, },
isMuted() { 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 StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } 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 AudioAsset = { id: string; originalName: string; mimeType: string }
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] } type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
type LevelTemplate = { versionId: string; slug: string; name: string; version: number } 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 [label, setLabel] = useState(node.label)
const [componentKey, setComponentKey] = useState(node.componentKey || '') 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 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' const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
return <div className="inspector-body"> 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>)} {audioAssets.map(asset => <option key={asset.id} value={asset.id}>{asset.originalName}</option>)}
</select> </select>
</label> </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> <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"> {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' 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 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 PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }