viewer: open the whole sound bank, not just the voice half
The library enumerator kept only names containing VOICE or \Briefing\, and read eng\sounds.tbl unconditionally. So the Explorer could reach 4382 of the 9519 banks in sound.pak: no music, no jingles, no sound effects, and no Japanese voice at all -- roughly half the disc's audio had no route to the UI. `slb::list_audio_entries` now returns every named bank with the category its path implies (Music / Jingles / Sound effects / Radio / Dialogue / Movie voice / Briefing). `list_voice_clips` is that, restricted to the spoken categories, so its existing test still guards the old behaviour. The 36 root banks carry no language component and appear whichever table is read; the window gets an English/Japanese switch that re-reads the other sounds.tbl, since the table name IS the selector. Two defects the decode found, both recorded in docs/re/structures/sound-pak-contents.md: * `Static.slb` -- the SFX bank -- declares 616768 bytes more than sound.p04 holds. Not our extraction: p04 matches the ISO's own directory record, and a sweep of every pak on the disc finds this one entry over-running and no other. It is the highest-offset entry, so its comp_size is an allocation size. A short read is now allowed for the tail entry ONLY; any other overrun stays an error, because clamping it would hide real damage behind a half-decoded asset. The bank went from unreadable to 514 s of audio. * the left-channel downmix was applied to everything. Right for voice (mono content however stored), wrong for music (a real stereo mix, half of it discarded). The caller now decides from the category. 35 of the 36 shared banks decode; JNGL_001 does not, and says so in the player instead of the panel silently closing. Its payload is not a whole number of XMA1 packets from any known data offset, so it is likely not a plain headerless stream -- written up rather than papered over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -465,6 +465,10 @@ pub struct AudioPreview {
|
||||
pub playing: bool,
|
||||
pub volume: f32,
|
||||
pub loading: bool,
|
||||
/// Set when a bank did not decode. Shown in the player instead of the panel
|
||||
/// vanishing: one bank on the disc (`JNGL_001.slb`) genuinely does not decode,
|
||||
/// and a player that silently closes is indistinguishable from a misclick.
|
||||
pub error: Option<String>,
|
||||
pub seek_request: Option<f32>,
|
||||
/// A freshly-decoded WAV + duration awaiting sink construction.
|
||||
pub(crate) pending: Option<(PathBuf, f32)>,
|
||||
@@ -481,6 +485,7 @@ impl Default for AudioPreview {
|
||||
playing: false,
|
||||
volume: 0.9,
|
||||
loading: false,
|
||||
error: None,
|
||||
seek_request: None,
|
||||
pending: None,
|
||||
generation: 0,
|
||||
@@ -498,23 +503,34 @@ pub struct RequestAudio {
|
||||
pub display: String,
|
||||
/// When set, resolve the `.slb` from the movie manifest instead of `clip`.
|
||||
pub movie: Option<(String, sylpheed_formats::slb::VoiceLang)>,
|
||||
/// Downmix to the left channel. True for spoken lines (mono content however
|
||||
/// it is stored); false for music, whose two channels are a real mix.
|
||||
pub mono: bool,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// The browseable library of voice-line clips (from `sounds.tbl`), for the
|
||||
/// standalone voice player. Loaded lazily the first time the browser opens.
|
||||
/// The browseable library of sound banks named in `sounds.tbl` — music,
|
||||
/// jingles, the SFX bank and every spoken line — for the standalone player.
|
||||
/// Loaded lazily the first time the browser opens, and re-loaded when the
|
||||
/// language changes (each `<lang>\sounds.tbl` names a different line set).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct VoiceLibrary {
|
||||
pub struct AudioLibrary {
|
||||
pub open: bool,
|
||||
pub loading: bool,
|
||||
pub loaded: bool,
|
||||
pub clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
||||
pub entries: Vec<sylpheed_formats::slb::AudioEntry>,
|
||||
pub filter: String,
|
||||
/// Which `<lang>\sounds.tbl` to read. The 36 root banks are shared, so they
|
||||
/// appear either way; the ~2 400–5 100 spoken lines do not.
|
||||
pub lang: sylpheed_formats::slb::VoiceLang,
|
||||
/// Set when the language changes; consumed by the loader like
|
||||
/// [`ScreenBrowser::rescan`], for the same B0002 reason.
|
||||
pub reload: bool,
|
||||
}
|
||||
|
||||
/// Ask the loader to enumerate the voice library from `sounds.tbl`.
|
||||
/// Ask the loader to enumerate the audio library from `sounds.tbl`.
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestVoiceLibrary;
|
||||
pub struct RequestAudioLibrary;
|
||||
|
||||
// ── Game-data browser (decoded IDXD tables) ────────────────────────────────────
|
||||
|
||||
@@ -946,9 +962,9 @@ enum IsoLoaderMsg {
|
||||
generation: u64,
|
||||
wav: Option<(PathBuf, f32)>,
|
||||
},
|
||||
/// The voice-clip library enumerated from `sounds.tbl`.
|
||||
VoiceLibraryLoaded {
|
||||
clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
||||
/// The sound-bank library enumerated from `<lang>\sounds.tbl`.
|
||||
AudioLibraryLoaded {
|
||||
entries: Vec<sylpheed_formats::slb::AudioEntry>,
|
||||
},
|
||||
/// The assembled-ship catalog for the Ships browser.
|
||||
ShipCatalogLoaded(Vec<ShipRow>),
|
||||
@@ -1180,7 +1196,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.init_resource::<MovieSubtitles>()
|
||||
.init_resource::<MovieVoice>()
|
||||
.init_resource::<AudioPreview>()
|
||||
.init_resource::<VoiceLibrary>()
|
||||
.init_resource::<AudioLibrary>()
|
||||
.init_resource::<GameData>()
|
||||
.init_resource::<ShipBrowser>()
|
||||
.init_resource::<ScreenBrowser>()
|
||||
@@ -1188,7 +1204,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.add_event::<RequestSubtitles>()
|
||||
.add_event::<RequestVoice>()
|
||||
.add_event::<RequestAudio>()
|
||||
.add_event::<RequestVoiceLibrary>()
|
||||
.add_event::<RequestAudioLibrary>()
|
||||
.add_event::<RequestGameData>()
|
||||
.add_event::<RequestShipCatalog>()
|
||||
.add_event::<RequestShipRender>()
|
||||
@@ -1222,7 +1238,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
handle_voice_request,
|
||||
handle_audio_request,
|
||||
advance_audio_playback,
|
||||
handle_voice_library_request,
|
||||
handle_audio_library_request,
|
||||
handle_game_data_request,
|
||||
handle_ship_catalog_request,
|
||||
handle_ship_render_request,
|
||||
@@ -2080,7 +2096,7 @@ fn poll_loader_channel(
|
||||
mut subtitles: ResMut<MovieSubtitles>,
|
||||
mut mvoice: ResMut<MovieVoice>,
|
||||
mut audio_preview: ResMut<AudioPreview>,
|
||||
mut voice_lib: ResMut<VoiceLibrary>,
|
||||
mut audio_lib: ResMut<AudioLibrary>,
|
||||
mut game_data: ResMut<GameData>,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
mut screens: ResMut<ScreenBrowser>,
|
||||
@@ -2105,7 +2121,7 @@ fn poll_loader_channel(
|
||||
// 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();
|
||||
*audio_lib = AudioLibrary::default();
|
||||
info!("Loaded {} files", browser.files.len());
|
||||
}
|
||||
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
|
||||
@@ -2178,23 +2194,28 @@ fn poll_loader_channel(
|
||||
match wav {
|
||||
Some((p, dur)) => {
|
||||
audio_preview.name = name;
|
||||
audio_preview.error = None;
|
||||
audio_preview.pending = Some((p, dur));
|
||||
}
|
||||
None => audio_preview.active = false,
|
||||
None => {
|
||||
audio_preview.name = name;
|
||||
audio_preview.error =
|
||||
Some("this bank did not decode to any audio".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => {
|
||||
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;
|
||||
Ok(IsoLoaderMsg::AudioLibraryLoaded { entries }) => {
|
||||
audio_lib.loading = false;
|
||||
// sounds.tbl always names thousands of banks, 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 entries.is_empty() {
|
||||
audio_lib.loaded = false;
|
||||
} else {
|
||||
voice_lib.clips = clips;
|
||||
voice_lib.loaded = true;
|
||||
audio_lib.entries = entries;
|
||||
audio_lib.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::GameDataLoaded(snap)) => {
|
||||
@@ -2258,7 +2279,7 @@ fn poll_loader_channel(
|
||||
saves.loading = false;
|
||||
game_data.loading = false;
|
||||
ships.loading = false;
|
||||
voice_lib.loading = false;
|
||||
audio_lib.loading = false;
|
||||
iso_state.error = Some("the asset loader stopped responding".into());
|
||||
break;
|
||||
}
|
||||
@@ -3665,6 +3686,7 @@ fn decode_sound_clip(
|
||||
source: &SourceKind,
|
||||
clip_name: &str,
|
||||
duration: f32,
|
||||
mono: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
use sylpheed_formats::{hash::name_hash, slb};
|
||||
let key = name_hash(clip_name);
|
||||
@@ -3683,15 +3705,25 @@ fn decode_sound_clip(
|
||||
// so the standalone browser can still play them.
|
||||
riffs = slb::to_xma_riff_best(&bytes).into_iter().collect();
|
||||
}
|
||||
decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration)
|
||||
decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration, mono)
|
||||
}
|
||||
|
||||
/// Decode a set of XMA sub-wave RIFFs into a single mono WAV: concatenate them,
|
||||
/// downmix to mono (left channel holds the signal for dual-mono sources), and
|
||||
/// clamp to `duration` seconds. Shared by the per-`.slb` decoder and the
|
||||
/// continuous movie-voice decoder.
|
||||
/// Decode a set of XMA sub-wave RIFFs into one WAV: concatenate them, optionally
|
||||
/// downmix to mono, and clamp to `duration` seconds. Shared by the per-`.slb`
|
||||
/// decoder and the continuous movie-voice decoder.
|
||||
///
|
||||
/// `mono` takes the LEFT channel only. That is right for **voice**, where the
|
||||
/// content is mono however it is stored (some clips put the signal in the left
|
||||
/// channel alone, others duplicate L=R) -- and wrong for **music**, where the two
|
||||
/// channels are a real stereo mix and taking one throws half of it away. So the
|
||||
/// caller decides from the bank's category rather than this guessing per-sample.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<PathBuf, String> {
|
||||
fn decode_riffs_to_wav(
|
||||
riffs: Vec<Vec<u8>>,
|
||||
tag: &str,
|
||||
duration: f32,
|
||||
mono: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
if riffs.is_empty() {
|
||||
return Err("no decodable audio".into());
|
||||
}
|
||||
@@ -3710,9 +3742,10 @@ fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<
|
||||
600.0
|
||||
};
|
||||
let filter = format!(
|
||||
"{}concat=n={}:v=0:a=1,pan=mono|c0=c0[a]",
|
||||
"{}concat=n={}:v=0:a=1{}[a]",
|
||||
(0..inputs.len()).map(|i| format!("[{i}:a]")).collect::<String>(),
|
||||
inputs.len(),
|
||||
if mono { ",pan=mono|c0=c0" } else { "" },
|
||||
);
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-y"]);
|
||||
@@ -3855,7 +3888,7 @@ fn decode_voice_region(
|
||||
if riffs.is_empty() {
|
||||
riffs = slb::to_xma_riff_best(&bytes).into_iter().collect();
|
||||
}
|
||||
decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration)
|
||||
decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration, true)
|
||||
}
|
||||
|
||||
/// Resolve a movie's `sound.pak` voice-entry name via the **movie manifest**
|
||||
@@ -3918,7 +3951,7 @@ fn handle_voice_request(
|
||||
if clip.contains("\\Movie\\") {
|
||||
return None;
|
||||
}
|
||||
decode_sound_clip(&source, &clip, duration).ok()
|
||||
decode_sound_clip(&source, &clip, duration, true).ok()
|
||||
});
|
||||
info!("[voice] movie={movie:?} gen={generation} -> wav={wav:?}");
|
||||
let _ = sender.send(IsoLoaderMsg::VoiceLoaded {
|
||||
@@ -3991,10 +4024,11 @@ fn handle_audio_request(
|
||||
};
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
let (clip, display, movie, generation) = (
|
||||
let (clip, display, movie, mono, generation) = (
|
||||
req.clip.clone(),
|
||||
req.display.clone(),
|
||||
req.movie.clone(),
|
||||
req.mono,
|
||||
req.generation,
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
@@ -4009,9 +4043,9 @@ fn handle_audio_request(
|
||||
if c.contains("\\Movie\\") {
|
||||
return None;
|
||||
}
|
||||
decode_sound_clip(&source, &c, f32::INFINITY).ok()
|
||||
decode_sound_clip(&source, &c, f32::INFINITY, true).ok()
|
||||
}),
|
||||
None => decode_sound_clip(&source, &clip, f32::INFINITY).ok(),
|
||||
None => decode_sound_clip(&source, &clip, f32::INFINITY, mono).ok(),
|
||||
}
|
||||
.and_then(|p| wav_duration(&p).map(|d| (p, d)));
|
||||
let _ = sender.send(IsoLoaderMsg::AudioLoaded {
|
||||
@@ -4786,36 +4820,51 @@ fn build_ship_model(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
|
||||
/// enumerates the voice clips off-thread.
|
||||
/// Handles a [`RequestAudioLibrary`]: reads `tables.pak`'s `<lang>\sounds.tbl`
|
||||
/// and enumerates every named sound bank off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_voice_library_request(
|
||||
mut events: EventReader<RequestVoiceLibrary>,
|
||||
fn handle_audio_library_request(
|
||||
mut events: EventReader<RequestAudioLibrary>,
|
||||
iso_state: Res<IsoState>,
|
||||
channels: Res<IsoChannels>,
|
||||
mut library: ResMut<AudioLibrary>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
// Either the View-menu event, or the in-window language switch's flag.
|
||||
let reload = library.reload;
|
||||
if events.read().next().is_none() && !reload {
|
||||
return;
|
||||
}
|
||||
library.reload = false;
|
||||
if library.loading {
|
||||
return;
|
||||
}
|
||||
library.loading = true;
|
||||
// The table name IS the language selector — there is no language field
|
||||
// inside it, so switching languages means reading the other file.
|
||||
let lang = library.lang;
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let clips = (|| -> Result<Vec<sylpheed_formats::slb::VoiceClip>, String> {
|
||||
let entries = (|| -> Result<Vec<sylpheed_formats::slb::AudioEntry>, String> {
|
||||
let pak = read_pak_archive_blocking(&source, "dat/tables.pak")?;
|
||||
let name = format!("{}\\sounds.tbl", lang_code(lang));
|
||||
let tbl = pak
|
||||
.read_by_name("eng\\sounds.tbl")
|
||||
.ok_or("sounds.tbl not found")?
|
||||
.read_by_name(&name)
|
||||
.ok_or(format!("{name} not found"))?
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(sylpheed_formats::slb::list_voice_clips(
|
||||
&tbl,
|
||||
sylpheed_formats::slb::VoiceLang::English,
|
||||
))
|
||||
Ok(sylpheed_formats::slb::list_audio_entries(&tbl, lang))
|
||||
})()
|
||||
.unwrap_or_default();
|
||||
let _ = sender.send(IsoLoaderMsg::VoiceLibraryLoaded { clips });
|
||||
let _ = sender.send(IsoLoaderMsg::AudioLibraryLoaded { entries });
|
||||
});
|
||||
}
|
||||
|
||||
/// `eng` / `jpn` — the `tables.pak` and `sound.pak` path prefix for a language.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn lang_code(lang: sylpheed_formats::slb::VoiceLang) -> &'static str {
|
||||
lang.code_pub()
|
||||
}
|
||||
|
||||
/// Drives the standalone audio player: builds/rebuilds its sink, mirrors
|
||||
/// play/pause + volume + seeks, advances the clock, and stops at the end.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -13,9 +13,9 @@ use crate::iso_loader::{
|
||||
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
||||
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
|
||||
RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose,
|
||||
RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary, SaveBrowser,
|
||||
RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestAudioLibrary, SaveBrowser,
|
||||
ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
|
||||
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
|
||||
VideoPreview, AudioLibrary, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
use sylpheed_formats::SubLang;
|
||||
@@ -148,7 +148,7 @@ struct UiEvents<'w> {
|
||||
file_selected: EventWriter<'w, FileSelected>,
|
||||
subtitles: EventWriter<'w, RequestSubtitles>,
|
||||
audio: EventWriter<'w, RequestAudio>,
|
||||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||||
audio_lib: EventWriter<'w, RequestAudioLibrary>,
|
||||
game_data: EventWriter<'w, RequestGameData>,
|
||||
ships: EventWriter<'w, RequestShipCatalog>,
|
||||
screens: EventWriter<'w, RequestScreenCatalog>,
|
||||
@@ -170,7 +170,7 @@ fn draw_viewer_ui(
|
||||
mut subtitles: ResMut<MovieSubtitles>,
|
||||
mut movie_voice: ResMut<MovieVoice>,
|
||||
mut audio: ResMut<AudioPreview>,
|
||||
mut voice_lib: ResMut<VoiceLibrary>,
|
||||
mut audio_lib: ResMut<AudioLibrary>,
|
||||
mut events: UiEvents,
|
||||
) {
|
||||
let ctx = contexts.ctx_mut();
|
||||
@@ -202,11 +202,11 @@ fn draw_viewer_ui(
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui.button("🎙 Voice Lines…").clicked() {
|
||||
voice_lib.open = true;
|
||||
if !voice_lib.loaded && !voice_lib.loading {
|
||||
voice_lib.loading = true;
|
||||
events.voice_lib.send_default();
|
||||
if ui.button("🔊 Audio Library…").clicked() {
|
||||
audio_lib.open = true;
|
||||
if !audio_lib.loaded && !audio_lib.loading {
|
||||
audio_lib.loading = true;
|
||||
events.audio_lib.send_default();
|
||||
}
|
||||
ui.close_menu();
|
||||
}
|
||||
@@ -248,98 +248,134 @@ fn draw_viewer_ui(
|
||||
});
|
||||
|
||||
// ── Standalone voice-line browser (floating window) ───────────────────
|
||||
if voice_lib.open {
|
||||
if audio_lib.open {
|
||||
let mut open = true;
|
||||
egui::Window::new("🎙 Voice Lines")
|
||||
.default_width(360.0)
|
||||
.default_height(480.0)
|
||||
egui::Window::new("🔊 Audio Library")
|
||||
.default_width(400.0)
|
||||
.default_height(520.0)
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
if voice_lib.loading {
|
||||
if audio_lib.loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Reading sounds.tbl…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
} else if !voice_lib.loaded {
|
||||
} else if !audio_lib.loaded {
|
||||
ui.label("Open a game source first.");
|
||||
} else {
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Voice:");
|
||||
// The table name IS the selector: there is no language
|
||||
// field inside sounds.tbl, so switching re-reads the
|
||||
// other file. Music/jingles/SFX are shared and stay.
|
||||
for lang in VoiceLang::ALL {
|
||||
if ui
|
||||
.selectable_label(audio_lib.lang == lang, lang.label())
|
||||
.clicked()
|
||||
&& audio_lib.lang != lang
|
||||
{
|
||||
audio_lib.lang = lang;
|
||||
audio_lib.loaded = false;
|
||||
audio_lib.entries.clear();
|
||||
audio_lib.reload = true;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Filter:");
|
||||
ui.text_edit_singleline(&mut voice_lib.filter);
|
||||
ui.text_edit_singleline(&mut audio_lib.filter);
|
||||
});
|
||||
let f = voice_lib.filter.to_lowercase();
|
||||
// Group the thousands of entries as directory → speaker so the
|
||||
// list is navigable (e.g. browse `Voice` by character to find a
|
||||
// cutscene's radio line). `name` is `<lang>\<dir>\<file>.slb`.
|
||||
let f = audio_lib.filter.to_lowercase();
|
||||
// Group category → speaker. The category comes from the path
|
||||
// shape, so the root banks (music/jingles/SFX) get real
|
||||
// headings instead of the "?" a directory split gave them.
|
||||
use std::collections::BTreeMap;
|
||||
let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> =
|
||||
BTreeMap::new();
|
||||
for c in &voice_lib.clips {
|
||||
if !f.is_empty() && !c.name.to_lowercase().contains(&f) {
|
||||
type Group<'a> = BTreeMap<&'a str, Vec<&'a sylpheed_formats::slb::AudioEntry>>;
|
||||
let mut groups: BTreeMap<
|
||||
sylpheed_formats::slb::AudioCategory,
|
||||
Group<'_>,
|
||||
> = BTreeMap::new();
|
||||
for e in &audio_lib.entries {
|
||||
if !f.is_empty() && !e.clip.name.to_lowercase().contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let dir = c.name.rsplit('\\').nth(1).unwrap_or("?");
|
||||
groups
|
||||
.entry(dir)
|
||||
.entry(e.category)
|
||||
.or_default()
|
||||
.entry(c.speaker.as_str())
|
||||
.entry(e.clip.speaker.as_str())
|
||||
.or_default()
|
||||
.push(c);
|
||||
.push(e);
|
||||
}
|
||||
let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len()))
|
||||
.weak()
|
||||
.small(),
|
||||
egui::RichText::new(format!(
|
||||
"{shown} / {} banks",
|
||||
audio_lib.entries.len()
|
||||
))
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
ui.separator();
|
||||
let filtering = !f.is_empty();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (dir, speakers) in &groups {
|
||||
let dtotal: usize = speakers.values().map(Vec::len).sum();
|
||||
egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})"))
|
||||
.id_salt(("vdir", *dir))
|
||||
.default_open(filtering)
|
||||
.show(ui, |ui| {
|
||||
for (speaker, clips) in speakers {
|
||||
for (cat, speakers) in &groups {
|
||||
let ctotal: usize = speakers.values().map(Vec::len).sum();
|
||||
egui::CollapsingHeader::new(format!(
|
||||
"{} ({ctotal})",
|
||||
cat.label()
|
||||
))
|
||||
.id_salt(("acat", *cat))
|
||||
.default_open(filtering || ctotal <= 40)
|
||||
.show(ui, |ui| {
|
||||
for (speaker, entries) in speakers {
|
||||
// A single-bank group (Static.slb) would be a
|
||||
// pointless nested header.
|
||||
let flat = speakers.len() == 1 || entries.len() == 1;
|
||||
let mut row = |ui: &mut egui::Ui| {
|
||||
for e in entries {
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button("▶")
|
||||
.on_hover_text(&e.clip.name)
|
||||
.clicked()
|
||||
{
|
||||
audio.generation =
|
||||
audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.error = None;
|
||||
audio.name = e.clip.display.clone();
|
||||
events.audio.send(RequestAudio {
|
||||
clip: e.clip.name.clone(),
|
||||
display: e.clip.display.clone(),
|
||||
movie: None,
|
||||
mono: e.category.is_voice(),
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
ui.label(&e.clip.display);
|
||||
});
|
||||
}
|
||||
};
|
||||
if flat {
|
||||
row(ui);
|
||||
} else {
|
||||
egui::CollapsingHeader::new(format!(
|
||||
"{speaker} ({})",
|
||||
clips.len()
|
||||
entries.len()
|
||||
))
|
||||
.id_salt(("vspk", *dir, *speaker))
|
||||
.default_open(filtering || clips.len() <= 6)
|
||||
.show(ui, |ui| {
|
||||
for c in clips {
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button("▶")
|
||||
.on_hover_text(&c.name)
|
||||
.clicked()
|
||||
{
|
||||
audio.generation =
|
||||
audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true;
|
||||
audio.name = c.display.clone();
|
||||
events.audio.send(RequestAudio {
|
||||
clip: c.name.clone(),
|
||||
display: c.display.clone(),
|
||||
movie: None,
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
ui.label(&c.display);
|
||||
});
|
||||
}
|
||||
});
|
||||
.id_salt(("aspk", *cat, *speaker))
|
||||
.default_open(filtering || entries.len() <= 6)
|
||||
.show(ui, &mut row);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
voice_lib.open = open;
|
||||
audio_lib.open = open;
|
||||
}
|
||||
|
||||
// ── Left panel: file browser ──────────────────────────────────────────
|
||||
@@ -481,11 +517,13 @@ fn draw_viewer_ui(
|
||||
audio.generation = audio.generation.wrapping_add(1);
|
||||
audio.loading = true;
|
||||
audio.active = true; // show the panel immediately (spinner)
|
||||
audio.error = None;
|
||||
audio.name = format!("VOICE_{movie}");
|
||||
events.audio.send(RequestAudio {
|
||||
clip: String::new(),
|
||||
display: format!("VOICE_{movie}"),
|
||||
movie: Some((movie.clone(), movie_voice.lang)),
|
||||
mono: true, // a cutscene voice track
|
||||
generation: audio.generation,
|
||||
});
|
||||
}
|
||||
@@ -1348,8 +1386,10 @@ fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
|
||||
if audio.loading {
|
||||
ui.spinner();
|
||||
ui.label("decoding…");
|
||||
} else if let Some(err) = &audio.error {
|
||||
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), format!("⚠ {err}"));
|
||||
} else {
|
||||
ui.label("voice track");
|
||||
ui.label("sound bank");
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("✖ Close").clicked() {
|
||||
|
||||
Reference in New Issue
Block a user