Add cutscene controls for the audio mixer, fix PSP pause latency

- Fixed PSP pause taking up to ~1.1s to actually go silent: its output
  thread ran independently of stream->state, draining its whole software
  ring regardless. audiostream_t.state is now volatile and the output
  thread checks AUDIO_STREAM_STATE_PLAYING before each hardware chunk,
  skipping output (without consuming the ring) while paused - pause now
  goes silent within about one chunk (~23ms) and resume has no gap.
- Added audioMixerPause/Resume/SetPan/SetLoop/FadeTo/IsFading to the
  mixer - immediate, channel-indexed primitives for cutscenes to drive.
  Fade transitions (fadeFrom/To/Duration/Time/Easing) live on
  audiomixerchannelstate_t and advance every frame in
  audioMixerChannelApplyVolume(), reusing the same easingApply()
  interpolation uifullbox_t already uses for screen fades.
- New src/dusk/rpg/cutscene/item/audio/ with 9 cutscene item types:
  AUDIO_PLAY (+ AUDIO_PLAY_SIMPLE/AUDIO_PLAY_LOOPED shorthands),
  AUDIO_STOP, AUDIO_PAUSE, AUDIO_RESUME, AUDIO_FADE (+ FADE_OUT/FADE_IN
  shorthands), AUDIO_FADE_WAIT, AUDIO_SET_PAN, AUDIO_SET_LOOP, and the
  combined AUDIO_SET - registered through the same enum/union/callback
  table/macro mechanism every other item type uses.
- Wired the same 9 types into the JSON-based (offline JSONC -> binary
  .cts) cutscene asset pipeline: tools/asset/cutscene/__main__.py's
  encoder and assetcutsceneloader.c's decoder. Verified round-trip by
  hand-encoding/decoding a test file covering all 9 types, and confirmed
  the two existing real cutscene files re-encode byte-identical.

Built and verified on Linux, PSP (Docker), GameCube (Docker) and Wii
(Docker).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-02 17:44:06 -05:00
co-authored by Claude Sonnet 5
parent 9c28d5a3b7
commit 9364768397
31 changed files with 1183 additions and 3 deletions
+53
View File
@@ -93,6 +93,15 @@ ITEM_TYPE = {
'SCENE': 31,
'SAVE_DEVICE_CHECK': 32,
'SAVE_LOAD_ALL_SLOTS': 33,
'AUDIO_PLAY': 35,
'AUDIO_STOP': 36,
'AUDIO_PAUSE': 37,
'AUDIO_RESUME': 38,
'AUDIO_FADE': 39,
'AUDIO_FADE_WAIT': 40,
'AUDIO_SET_PAN': 41,
'AUDIO_SET_LOOP': 42,
'AUDIO_SET': 43,
}
PAUSE_FLAG = {'NPC': 1 << 0, 'PLAYER': 1 << 1, 'WORLD': 1 << 2, 'BATTLE': 1 << 3}
@@ -122,6 +131,10 @@ BATTLE_STATE = {
'AI_SELECTION': 4, 'MOVES_EXECUTING': 5, 'POST_ROUND': 6, 'ENDED': 7,
}
SCENE_TYPE = {'NULL': 0, 'INITIAL': 1, 'MAIN_MENU': 2, 'OVERWORLD': 3, 'BATTLE': 4}
AUDIO_CHANNEL = {
'BGM_0': 0, 'VOICE_0': 1, 'VOICE_1': 2,
'SFX_0': 3, 'SFX_1': 4, 'SFX_2': 5, 'SFX_3': 6,
}
CUTSCENE_TEXT_MAX_CHARS = 256
CUTSCENE_TEXT_MINI_MAX_CHARS = 128
@@ -131,6 +144,7 @@ CUTSCENE_MODAL_MESSAGE_MAX_CHARS = 256
CUTSCENE_MODAL_OPTIONS_MARKERS_MAX = 2
CUTSCENE_MAP_AREA_WAIT_MAX = 4
CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX = 4
AUDIO_PATH_MAX = 256
class Pool:
@@ -331,6 +345,45 @@ def encode_item(item, pool):
failure_offset = pool.add_string(item['failureMarker'])
buf += struct.pack('<HH', success_offset, failure_offset)
elif item_type == 'AUDIO_PLAY':
write_string_field(buf, item['file'], AUDIO_PATH_MAX)
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item.get('volume', 1.0)))
buf += struct.pack('<f', float(item.get('pan', 0.0)))
buf += struct.pack('<B', 1 if item.get('looping', False) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
elif item_type in ('AUDIO_STOP', 'AUDIO_PAUSE', 'AUDIO_RESUME', 'AUDIO_FADE_WAIT'):
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
elif item_type == 'AUDIO_FADE':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['from']))
buf += struct.pack('<f', float(item['to']))
buf += struct.pack('<f', float(item['duration']))
buf += struct.pack('<B', EASING[item.get('easing', 'LINEAR').upper()])
elif item_type == 'AUDIO_SET_PAN':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['pan']))
elif item_type == 'AUDIO_SET_LOOP':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<B', 1 if item.get('looping', True) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
elif item_type == 'AUDIO_SET':
buf += struct.pack('<B', AUDIO_CHANNEL[item['channel'].upper()])
buf += struct.pack('<f', float(item['pan']))
buf += struct.pack('<B', 1 if item.get('looping', True) else 0)
buf += struct.pack('<B', int(item.get('loopCount', 0)))
buf += struct.pack('<f', float(item.get('loopStart', -1.0)))
buf += struct.pack('<f', float(item.get('loopTo', 0.0)))
else:
raise ValueError(f"Unhandled cutscene item type: {item_type}")