port: the intro had no dialogue because the voice is a separate asset, and I concatenated it wrongly first

A human play-test heard music under the boot intro and no voices. The obvious
reading -- the 5.1 fold dropped the centre channel -- is wrong. `ADV.wmv` carries
music and effects only; a cutscene's voice is a separate continuous XMA stream in
`sound.pak`, bound to the movie by the manifest in `tables.pak`. Nothing was
dropped. The exporter had never been asked for it, so every fidelity measurement
in AUDIO-VERIFICATION.md would have come back clean.

`audio::export_voice` resolves it with `media::resolve_movie_voice_region` and
never by filename: `RT01A`'s voice lives inside `VOICE_ADV.slb`, so a name match
is correct on exactly the two movies this port would have spot-checked. Decoded,
not authored -- so it runs outside the `authored/audio.json` block.

THE FIRST VERSION CONCATENATED THE REGION'S CHUNKS AND WAS WRONG. It produced
359 s of dialogue for a 137 s movie. Decoding and timing each chunk shows two of
them equal to six decimals and each spanning the whole movie -- HANDOFF Q10's
decoded two-stem shape on a second asset kind -- so they are summed at 1/n. The
error was visible only because the first version recorded the decoded length
against the movie's instead of clamping to it; the clamp `media`'s own doc
comment invites, and which `sylpheed-viewer` applies, would have produced a file
of exactly the right duration containing the wrong audio.

The dropped leading chunk matches no duration in its region and is NOT closed
here. It is the same signature as `BGM_103`'s third sub-wave, already open in
BLOCKED.md, now corroborated on an independent asset kind. Raised with the
Decoder; the manifest names every chunk dropped and its length.

Also in this commit, and separable:

* `--skip-at=SECONDS` -- `--script` structurally cannot press during a movie,
  because `_script_settled` waits while `_player != null`. That is why "does (A)
  skip the intro" had been read out of the source rather than measured.
* MISSION section 6 pins a 5.1->stereo matrix and this exporter has shipped a
  different one since P4 -- the same weighting, 7.65 dB quieter -- and said so
  nowhere. Re-measured with the right instrument (float decode, whole file, count
  the samples that would clamp, not a peak reading): the pinned matrix puts ADV
  at +4.26 dBFS on 4406 samples, while S00A never clips. So the pin overloads one
  movie and the constant is over-broad for the other. NOT changed -- the level of
  a mix is what section 6 reserves to a human. The export now carries a warning
  with the numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-29 14:51:38 +00:00
parent 3a4c6ac9df
commit 53a93e2e9e
10 changed files with 687 additions and 8 deletions

View File

@@ -120,6 +120,7 @@ func _ready() -> void:
_shots = args.get("shots", "")
if args.has("script"):
_script = args["script"].split(",", false)
_skip_at = float(args.get("skip-at", "0"))
# P5. `--play` boots first and hands over on the title; `--menu` starts on a
# screen directly, which is what makes an unattended run cheap -- it does not
# sit through 137 s of intro to press a d-pad.
@@ -242,6 +243,9 @@ var _play := false
var _pending: Variant = null
var _script: PackedStringArray = PackedStringArray()
var _shots := ""
## `--skip-at=SECONDS`: when to send a synthetic (A) during a movie, or 0.
var _skip_at := 0.0
var _skip_sent := false
var _script_started := false
var _sequence: Array[Dictionary] = []
var _player: VideoStreamPlayer = null
@@ -262,6 +266,20 @@ func _process(delta: float) -> void:
_overlay_process(delta)
if _player != null:
# `--skip-at=SECONDS` presses (A) at a wall-clock moment DURING a movie,
# which `--script` structurally cannot do: `_script_settled` waits while
# `_player != null`, so a scripted walk only ever starts after the movie
# has ended. That gap is why "does (A) skip the intro" had been read out
# of the source rather than measured, and a human play-test then found
# it not working.
#
# It goes through `Input.parse_input_event`, like `_press` -- the wiring
# between a press and `_unhandled_input` is the thing under test, so a
# direct call to `_video_finished` would prove nothing.
if _skip_at > 0.0 and _elapsed >= _skip_at and not _skip_sent:
_skip_sent = true
print(" --skip-at: pressing (A) at %.2f s" % _elapsed)
_press("ui_accept")
return
# A menu transition. This is checked BEFORE the boot sequence and outside
@@ -367,6 +385,17 @@ func _play_video(name: String, skippable: bool) -> void:
await get_tree().process_frame
_player.finished.connect(_video_finished)
_player.play()
# The dialogue is a SECOND stream, started with the picture. `ADV.wmv` and
# `S00A.wmv` carry music and effects only; the voice is a separate asset the
# exporter resolves off the movie manifest. Started after `play()` and in the
# same frame, because the offset between them is zero and adding a wait here
# would be authoring a sync constant nobody measured.
if audio.play_voice(name):
print(" + voice %s" % name)
else:
# Said out loud: silence is the audio failure that looks like success,
# and "this cutscene is unvoiced" is a real answer for most of the disc.
print(" no voice track for %s in this export" % name)
var _skippable := false
@@ -374,6 +403,9 @@ var _skippable := false
func _video_finished() -> void:
print(" video ended at %.2f s" % _elapsed)
# Before anything else: a voice that outlived a skipped intro would play on
# over the title screen, which is the sort of bug that sounds like a feature.
audio.stop_voice()
_player.queue_free()
_player = null
# A movie the MENU started (P7) returns to an authored screen; a movie the