feat(formats,viewer): standalone voice player covers all line categories
list_voice_clips now enumerates every spoken-line category so the standalone audio player covers them all — bound movie voices (\Movie\), in-mission radio (\Voice\, \etc\), and mission briefings (\Briefing\, whose BR<NN>_<MM> names lack "VOICE"); music (BGM_*) stays excluded. Viewer voice-library fixes: invalidate the library when a new game source loads, and don't latch an empty result as "loaded" (sounds.tbl always has thousands of clips, so empty = a failed read) so re-opening the menu retries. Voice decode reverted to the first sub-wave (base behaviour): a naive multi-sub-wave concat produced the wrong track for RT movies. The per-sub-wave splitter (to_xma_riffs) is kept + documented for the eventual real fix, which needs ground-truth playback instrumentation to resolve segments-vs-takes-vs-timecode placement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -79,7 +79,12 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
|
||||
}
|
||||
if i - start >= 6 {
|
||||
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
|
||||
if s.starts_with(&prefix) && s.ends_with(".slb") && s.contains("VOICE") {
|
||||
// Every spoken-line category, so the standalone player covers them
|
||||
// all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices
|
||||
// (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in
|
||||
// `\Briefing\` as `BR<NN>_<MM>.slb` (no `VOICE` in the name).
|
||||
let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\");
|
||||
if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice {
|
||||
if seen.insert(s.to_string()) {
|
||||
out.push(parse_voice_clip(s));
|
||||
}
|
||||
@@ -113,8 +118,58 @@ fn parse_voice_clip(name: &str) -> VoiceClip {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in
|
||||
/// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves
|
||||
/// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`,
|
||||
/// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be
|
||||
/// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller
|
||||
/// decodes each to PCM, concatenates in order, and clamps to the movie length —
|
||||
/// that yields the full track for segment banks while the clamp drops the
|
||||
/// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O
|
||||
/// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.)
|
||||
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
if find(slb, b"RIFF", 0).is_none() {
|
||||
// Headerless single-stream bank.
|
||||
if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) {
|
||||
if !data.is_empty() {
|
||||
out.push(build_riff(&synth_xma1_fmt(2, 2, 48000), data));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut pos = 0usize;
|
||||
while let Some(ri) = find(slb, b"RIFF", pos) {
|
||||
// Parse this sub-wave's fmt + data (declared size is honest per sub-wave).
|
||||
let Some(fi) = find(slb, b"fmt ", ri) else { break };
|
||||
let Some(fsz) = le32(slb, fi + 4) else { break };
|
||||
let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else {
|
||||
break;
|
||||
};
|
||||
if fmt_end > slb.len() {
|
||||
break;
|
||||
}
|
||||
let Some(di) = find(slb, b"data", fi) else { break };
|
||||
let Some(dsz) = le32(slb, di + 4) else { break };
|
||||
let Some(ds) = di.checked_add(8) else { break };
|
||||
let de = ds
|
||||
.checked_add(dsz as usize)
|
||||
.unwrap_or(slb.len())
|
||||
.min(slb.len());
|
||||
if let Some(data) = slb.get(ds..de) {
|
||||
if !data.is_empty() {
|
||||
out.push(build_riff(&slb[fi..fmt_end], data));
|
||||
}
|
||||
}
|
||||
// Advance past this sub-wave's data to find the next RIFF.
|
||||
pos = de.max(ri + 4);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's
|
||||
/// `xma1`. Returns `None` if the bank is too small / malformed.
|
||||
/// `xma1`. Returns `None` if the bank is too small / malformed. This is the
|
||||
/// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks.
|
||||
pub fn to_xma_riff(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(ri) = find(slb, b"RIFF", 0) {
|
||||
// RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each
|
||||
@@ -235,6 +290,40 @@ mod tests {
|
||||
assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_voice_clips_covers_movie_radio_and_briefing() {
|
||||
// The standalone player must enumerate every spoken-line category: bound
|
||||
// movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\,
|
||||
// whose BR<NN>_<MM> names lack "VOICE"). Music (BGM_*) must stay excluded.
|
||||
let mut tbl = Vec::new();
|
||||
for s in [
|
||||
"eng\\Movie\\VOICE_S13A.slb",
|
||||
"eng\\etc\\VOICE_D_450.slb",
|
||||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||||
"eng\\Briefing\\BR01_01.slb",
|
||||
"eng\\bgm\\BGM_001.slb",
|
||||
] {
|
||||
tbl.extend_from_slice(s.as_bytes());
|
||||
tbl.push(0);
|
||||
}
|
||||
let names: Vec<String> = list_voice_clips(&tbl, VoiceLang::English)
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect();
|
||||
for want in [
|
||||
"eng\\Movie\\VOICE_S13A.slb",
|
||||
"eng\\etc\\VOICE_D_450.slb",
|
||||
"eng\\Voice\\VOICE_ADAN_010.slb",
|
||||
"eng\\Briefing\\BR01_01.slb",
|
||||
] {
|
||||
assert!(names.iter().any(|n| n == want), "missing {want}");
|
||||
}
|
||||
assert!(
|
||||
!names.iter().any(|n| n.contains("BGM_")),
|
||||
"music must not be listed as a voice clip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilds_riff_from_headerless() {
|
||||
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];
|
||||
|
||||
@@ -1698,6 +1698,10 @@ fn poll_loader_channel(
|
||||
browser.files = files;
|
||||
browser.selected = None;
|
||||
browser.filter.clear();
|
||||
// A new game source is loaded — invalidate the voice library so the
|
||||
// browser re-reads sounds.tbl from THIS source next time it opens
|
||||
// (otherwise a stale/failed empty result from before load persists).
|
||||
*voice_lib = VoiceLibrary::default();
|
||||
info!("Loaded {} files", browser.files.len());
|
||||
}
|
||||
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
|
||||
@@ -1773,9 +1777,17 @@ fn poll_loader_channel(
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => {
|
||||
voice_lib.clips = clips;
|
||||
voice_lib.loaded = true;
|
||||
voice_lib.loading = false;
|
||||
// sounds.tbl always has thousands of clips, so an empty result means
|
||||
// the read failed (e.g. no game source, or opened only a bare pak).
|
||||
// Leave `loaded=false` so re-opening the menu retries instead of
|
||||
// latching an empty list forever.
|
||||
if clips.is_empty() {
|
||||
voice_lib.loaded = false;
|
||||
} else {
|
||||
voice_lib.clips = clips;
|
||||
voice_lib.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::Cancelled) => {
|
||||
iso_state.loading = false;
|
||||
@@ -3128,6 +3140,10 @@ fn decode_sound_clip(
|
||||
use sylpheed_formats::{hash::name_hash, slb};
|
||||
let key = name_hash(clip_name);
|
||||
let bytes = read_sound_entry(source, key)?;
|
||||
// Decode the first sub-wave (base behaviour). NOTE: multi-sub-wave assembly
|
||||
// (segments vs alternate takes vs timecode-placed lines) is UNRESOLVED — a
|
||||
// naive concat produced the wrong track for RT movies. Pending ground-truth
|
||||
// voice-playback instrumentation (Canary APU/XMA) before a real fix.
|
||||
let riff = slb::to_xma_riff(&bytes).ok_or("not a decodable .slb")?;
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
@@ -3143,8 +3159,6 @@ fn decode_sound_clip(
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-y", "-i"])
|
||||
.arg(&xma_path)
|
||||
// Downmix to mono using the left channel (holds the full signal for both
|
||||
// left-only and dual-mono sources), then clamp to the media length.
|
||||
.args(["-af", "pan=mono|c0=c0", "-t"])
|
||||
.arg(format!("{dur}"))
|
||||
.arg(&out_path)
|
||||
|
||||
Reference in New Issue
Block a user