Skip to main content

siglus_scene_vm/audio/
bgm.rs

1use std::fs;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4
5use anyhow::{bail, Context, Result};
6
7use siglus_assets::{nwa, ovk};
8
9/// Supported container types for Siglus BGM inputs.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BgmContainer {
12    /// Siglus NWA (packed PCM).
13    Nwa,
14    /// Siglus OVK pack (Ogg/Vorbis entries).
15    Ovk,
16    /// Siglus OWP (XOR-obfuscated Ogg/Vorbis).
17    Owp,
18    /// Plain Ogg/Vorbis.
19    Ogg,
20    /// Plain WAV.
21    Wav,
22    /// Unknown (fallback).
23    Unknown,
24}
25
26impl BgmContainer {
27    pub fn from_path(path: &Path) -> BgmContainer {
28        let ext = path
29            .extension()
30            .and_then(|s| s.to_str())
31            .unwrap_or("")
32            .to_ascii_lowercase();
33        match ext.as_str() {
34            "nwa" => BgmContainer::Nwa,
35            "ovk" => BgmContainer::Ovk,
36            "owp" => BgmContainer::Owp,
37            "ogg" => BgmContainer::Ogg,
38            "wav" => BgmContainer::Wav,
39            _ => BgmContainer::Unknown,
40        }
41    }
42}
43
44
45/// Encoded audio payload format used directly for BGM playback.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum BgmPlaybackFormat {
48    Ogg,
49    Wav,
50}
51
52/// BGM payload prepared for playback without unnecessary quality-reducing conversion.
53#[derive(Debug, Clone)]
54pub struct BgmPlaybackData {
55    pub container: BgmContainer,
56    pub format: BgmPlaybackFormat,
57    pub bytes: Vec<u8>,
58    pub channels: u16,
59    pub sample_rate: u32,
60    pub total_samples: u64,
61    pub description: String,
62}
63
64#[derive(Debug, Clone, Copy)]
65struct BasicAudioInfo {
66    channels: u16,
67    sample_rate: u32,
68    total_samples: u64,
69}
70
71fn inspect_pcm_wav_bytes(wav: &[u8]) -> Result<BasicAudioInfo> {
72    if wav.len() < 44 || &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" {
73        bail!("not a RIFF/WAVE file");
74    }
75
76    let mut pos = 12usize;
77    let mut channels: Option<u16> = None;
78    let mut sample_rate: Option<u32> = None;
79    let mut block_align: Option<usize> = None;
80    let mut data_len: Option<usize> = None;
81
82    while pos + 8 <= wav.len() {
83        let id = &wav[pos..pos + 4];
84        let sz = u32::from_le_bytes([wav[pos + 4], wav[pos + 5], wav[pos + 6], wav[pos + 7]]) as usize;
85        pos += 8;
86        if pos + sz > wav.len() {
87            bail!("truncated WAV chunk");
88        }
89        if id == b"fmt " {
90            if sz < 16 {
91                bail!("truncated WAV fmt chunk");
92            }
93            channels = Some(u16::from_le_bytes([wav[pos + 2], wav[pos + 3]]).max(1));
94            sample_rate = Some(u32::from_le_bytes([
95                wav[pos + 4],
96                wav[pos + 5],
97                wav[pos + 6],
98                wav[pos + 7],
99            ]).max(1));
100            block_align = Some(u16::from_le_bytes([wav[pos + 12], wav[pos + 13]]) as usize);
101        } else if id == b"data" {
102            data_len = Some(sz);
103        }
104        pos += sz;
105        if (sz & 1) != 0 {
106            pos += 1;
107        }
108    }
109
110    let channels = channels.context("WAV fmt chunk missing channels")?;
111    let sample_rate = sample_rate.context("WAV fmt chunk missing sample rate")?;
112    let block_align = block_align.context("WAV fmt chunk missing block align")?.max(1);
113    let data_len = data_len.context("WAV data chunk missing")?;
114    Ok(BasicAudioInfo {
115        channels,
116        sample_rate,
117        total_samples: (data_len / block_align) as u64,
118    })
119}
120
121fn inspect_ogg_vorbis_bytes(ogg: &[u8]) -> Result<BasicAudioInfo> {
122    let mut pos = 0usize;
123    let mut channels: Option<u16> = None;
124    let mut sample_rate: Option<u32> = None;
125    let mut max_granule: i64 = -1;
126
127    while pos + 27 <= ogg.len() {
128        if &ogg[pos..pos + 4] != b"OggS" {
129            bail!("invalid Ogg capture pattern at byte {}", pos);
130        }
131        let granule = i64::from_le_bytes([
132            ogg[pos + 6],
133            ogg[pos + 7],
134            ogg[pos + 8],
135            ogg[pos + 9],
136            ogg[pos + 10],
137            ogg[pos + 11],
138            ogg[pos + 12],
139            ogg[pos + 13],
140        ]);
141        if granule >= 0 {
142            max_granule = max_granule.max(granule);
143        }
144        let seg_count = ogg[pos + 26] as usize;
145        let seg_table = pos + 27;
146        let data_start = seg_table + seg_count;
147        if data_start > ogg.len() {
148            bail!("truncated Ogg segment table");
149        }
150        let page_data_len = ogg[seg_table..data_start]
151            .iter()
152            .fold(0usize, |acc, b| acc.saturating_add(*b as usize));
153        let data_end = data_start.saturating_add(page_data_len);
154        if data_end > ogg.len() {
155            bail!("truncated Ogg page data");
156        }
157
158        let mut packet_off = data_start;
159        let mut packet_len = 0usize;
160        for lace in &ogg[seg_table..data_start] {
161            packet_len = packet_len.saturating_add(*lace as usize);
162            if *lace < 255 {
163                if packet_off + packet_len <= data_end {
164                    let packet = &ogg[packet_off..packet_off + packet_len];
165                    if packet.len() >= 16 && packet[0] == 1 && &packet[1..7] == b"vorbis" {
166                        channels = Some((packet[11] as u16).max(1));
167                        sample_rate = Some(u32::from_le_bytes([
168                            packet[12],
169                            packet[13],
170                            packet[14],
171                            packet[15],
172                        ]).max(1));
173                    }
174                }
175                packet_off = packet_off.saturating_add(packet_len);
176                packet_len = 0;
177            }
178        }
179
180        pos = data_end;
181    }
182
183    let channels = channels.context("Vorbis identification header missing")?;
184    let sample_rate = sample_rate.context("Vorbis identification sample rate missing")?;
185    if max_granule < 0 {
186        bail!("Ogg Vorbis final granule position missing");
187    }
188    Ok(BasicAudioInfo {
189        channels,
190        sample_rate,
191        total_samples: max_granule as u64,
192    })
193}
194
195
196#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
197fn read_audio_container_bytes(path: &Path) -> Result<Vec<u8>> {
198    crate::resource::read_file_bytes(path)
199        .with_context(|| format!("read audio container: {}", path.display()))
200}
201
202#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
203fn read_audio_container_bytes(path: &Path) -> Result<Vec<u8>> {
204    fs::read(path).with_context(|| format!("read audio container: {}", path.display()))
205}
206
207#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
208fn parse_ovk_entries_from_bytes(bytes: &[u8]) -> Result<Vec<ovk::OvkEntry>> {
209    if bytes.len() < 4 {
210        bail!("OVK: header too small");
211    }
212    let count = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
213    if count == 0 {
214        bail!("OVK: zero entries");
215    }
216    let table_len = 4usize.saturating_add(count.saturating_mul(16));
217    if table_len > bytes.len() {
218        bail!("OVK: entry table truncated");
219    }
220    let mut entries = Vec::with_capacity(count);
221    for i in 0..count {
222        let off = 4 + i * 16;
223        let size = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
224        let offset = u32::from_le_bytes(bytes[off + 4..off + 8].try_into().unwrap());
225        let no = u32::from_le_bytes(bytes[off + 8..off + 12].try_into().unwrap());
226        let sample_count = u32::from_le_bytes(bytes[off + 12..off + 16].try_into().unwrap());
227        let end = (offset as usize).saturating_add(size as usize);
228        if size != 0 && end > bytes.len() {
229            bail!("OVK entry[{i}] out of range: offset={} size={} len={}", offset, size, bytes.len());
230        }
231        entries.push(ovk::OvkEntry { size, offset, no, sample_count });
232    }
233    Ok(entries)
234}
235
236#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
237fn extract_ovk_entry_from_bytes(bytes: &[u8], idx: usize) -> Result<Vec<u8>> {
238    let entries = parse_ovk_entries_from_bytes(bytes)?;
239    let entry = entries
240        .get(idx)
241        .copied()
242        .ok_or_else(|| anyhow::anyhow!("OVK entry out of range: idx={} entries={}", idx, entries.len()))?;
243    if entry.size == 0 {
244        bail!("OVK entry[{idx}] has zero size");
245    }
246    let start = entry.offset as usize;
247    let end = start.saturating_add(entry.size as usize);
248    if end > bytes.len() {
249        bail!("OVK entry[{idx}] out of range");
250    }
251    Ok(bytes[start..end].to_vec())
252}
253
254#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
255fn decrypt_owp_bytes(mut bytes: Vec<u8>) -> Vec<u8> {
256    for b in &mut bytes {
257        *b ^= ovk::OwpFile::DEFAULT_XOR_KEY;
258    }
259    bytes
260}
261
262/// Prepare BGM bytes for direct playback.
263///
264/// Vorbis-based containers are kept as encoded Ogg/Vorbis bytes so Kira/Symphonia
265/// decodes the original stream directly. Only NWA is converted to PCM WAV because
266/// it is a Siglus PCM compression format rather than a Vorbis stream.
267pub fn decode_bgm_to_playback_bytes(
268    input: impl AsRef<Path>,
269    entry_idx: Option<usize>,
270) -> Result<BgmPlaybackData> {
271    let input = input.as_ref();
272    let kind = BgmContainer::from_path(input);
273
274    match kind {
275        BgmContainer::Ovk | BgmContainer::Owp | BgmContainer::Ogg => {
276            let (ogg, description) = extract_ogg_bytes(input, entry_idx)?;
277            let info = inspect_ogg_vorbis_bytes(&ogg)
278                .with_context(|| format!("inspect Ogg/Vorbis BGM: {}", input.display()))?;
279            Ok(BgmPlaybackData {
280                container: kind,
281                format: BgmPlaybackFormat::Ogg,
282                bytes: ogg,
283                channels: info.channels,
284                sample_rate: info.sample_rate,
285                total_samples: info.total_samples,
286                description,
287            })
288        }
289        BgmContainer::Wav => {
290            let wav = read_audio_container_bytes(input)?;
291            let info = inspect_pcm_wav_bytes(&wav)
292                .with_context(|| format!("inspect WAV BGM: {}", input.display()))?;
293            Ok(BgmPlaybackData {
294                container: kind,
295                format: BgmPlaybackFormat::Wav,
296                bytes: wav,
297                channels: info.channels,
298                sample_rate: info.sample_rate,
299                total_samples: info.total_samples,
300                description: format!("WAV:{}", input.display()),
301            })
302        }
303        BgmContainer::Nwa => {
304            let mut reader = open_nwa_reader(input)?;
305            let wav = reader.to_wav_bytes().context("decode NWA -> WAV")?;
306            let info = inspect_pcm_wav_bytes(&wav)
307                .with_context(|| format!("inspect NWA-decoded WAV: {}", input.display()))?;
308            Ok(BgmPlaybackData {
309                container: kind,
310                format: BgmPlaybackFormat::Wav,
311                bytes: wav,
312                channels: info.channels,
313                sample_rate: info.sample_rate,
314                total_samples: info.total_samples,
315                description: format!("NWA:{}", input.display()),
316            })
317        }
318        BgmContainer::Unknown => {
319            bail!(
320                "unsupported BGM container (by extension): {}",
321                input.display()
322            );
323        }
324    }
325}
326
327
328#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
329fn open_nwa_reader(input: &Path) -> Result<nwa::NwaReader> {
330    let bytes = read_audio_container_bytes(input)?;
331    nwa::NwaReader::open_from_bytes(bytes)
332        .with_context(|| format!("open NWA from wasm VFS: {}", input.display()))
333}
334
335#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
336fn open_nwa_reader(input: &Path) -> Result<nwa::NwaReader> {
337    nwa::NwaReader::open(input).with_context(|| format!("open NWA: {}", input.display()))
338}
339
340/// Decoded audio payload ready for export or playback.
341#[derive(Debug, Clone)]
342pub struct BgmDecoded {
343    pub container: BgmContainer,
344    /// WAV (PCM16) bytes.
345    pub wav_bytes: Vec<u8>,
346    /// A helpful description for logs/UI.
347    pub description: String,
348}
349
350#[derive(Debug, Clone)]
351pub enum KoeSource {
352    File(PathBuf),
353    OvkEntryByNo { path: PathBuf, entry_no: u32 },
354}
355
356/// Decode various Siglus audio containers into WAV (PCM16).
357///
358/// * For `.ovk`, `entry_idx` selects which entry to decode.
359/// * For other formats, `entry_idx` is ignored.
360#[allow(clippy::needless_pass_by_value)]
361pub fn decode_bgm_to_wav_bytes(
362    input: impl AsRef<Path>,
363    entry_idx: Option<usize>,
364) -> Result<BgmDecoded> {
365    let input = input.as_ref();
366    let kind = BgmContainer::from_path(input);
367
368    match kind {
369        BgmContainer::Nwa => {
370            let mut reader = open_nwa_reader(input)?;
371            let wav_bytes = reader.to_wav_bytes().context("decode NWA -> WAV")?;
372            Ok(BgmDecoded {
373                container: kind,
374                wav_bytes,
375                description: format!("NWA:{}", input.display()),
376            })
377        }
378        BgmContainer::Ovk => {
379            let idx = entry_idx.unwrap_or(0);
380            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
381            {
382                let bytes = read_audio_container_bytes(input)?;
383                let ogg = extract_ovk_entry_from_bytes(&bytes, idx).context("extract OVK entry")?;
384                let wav_bytes = siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
385                    .context("decode OVK(entry) -> WAV")?;
386                Ok(BgmDecoded {
387                    container: kind,
388                    wav_bytes,
389                    description: format!("OVK:{}[{}]", input.display(), idx),
390                })
391            }
392            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
393            {
394                let pack = ovk::OvkPack::open(input)
395                    .with_context(|| format!("open OVK: {}", input.display()))?;
396                let entry_cnt = pack.entries().len();
397                if idx >= entry_cnt {
398                    bail!("OVK entry out of range: idx={} entries={}", idx, entry_cnt);
399                }
400                let wav_bytes = pack
401                    .decode_entry_vorbis_wav(idx)
402                    .context("decode OVK(entry) -> WAV")?;
403                Ok(BgmDecoded {
404                    container: kind,
405                    wav_bytes,
406                    description: format!("OVK:{}[{}]", input.display(), idx),
407                })
408            }
409        }
410        BgmContainer::Owp => {
411            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
412            {
413                let bytes = read_audio_container_bytes(input)?;
414                let ogg = decrypt_owp_bytes(bytes);
415                let wav_bytes = siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
416                    .context("decode OWP -> WAV")?;
417                Ok(BgmDecoded {
418                    container: kind,
419                    wav_bytes,
420                    description: format!("OWP:{}", input.display()),
421                })
422            }
423            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
424            {
425                let owp = ovk::OwpFile::open(input)
426                    .with_context(|| format!("open OWP: {}", input.display()))?;
427                let wav_bytes = owp.decode_vorbis_wav().context("decode OWP -> WAV")?;
428                Ok(BgmDecoded {
429                    container: kind,
430                    wav_bytes,
431                    description: format!("OWP:{}", input.display()),
432                })
433            }
434        }
435        BgmContainer::Ogg => {
436            let bytes = read_audio_container_bytes(input)?;
437            let wav_bytes =
438                siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(bytes))
439                    .context("decode OGG/Vorbis -> WAV")?;
440            Ok(BgmDecoded {
441                container: kind,
442                wav_bytes,
443                description: format!("OGG:{}", input.display()),
444            })
445        }
446        BgmContainer::Wav => {
447            let wav_bytes = read_audio_container_bytes(input)?;
448            Ok(BgmDecoded {
449                container: kind,
450                wav_bytes,
451                description: format!("WAV:{}", input.display()),
452            })
453        }
454        BgmContainer::Unknown => {
455            bail!(
456                "unsupported BGM container (by extension): {}",
457                input.display()
458            );
459        }
460    }
461}
462
463pub fn decode_ovk_entry_by_no_to_wav_bytes(
464    input: impl AsRef<Path>,
465    entry_no: u32,
466) -> Result<BgmDecoded> {
467    let input = input.as_ref();
468    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
469    {
470        let bytes = read_audio_container_bytes(input)?;
471        let entries = parse_ovk_entries_from_bytes(&bytes)?;
472        let idx = entries
473            .iter()
474            .position(|e| e.no == entry_no)
475            .with_context(|| {
476                format!(
477                    "OVK entry not found: no={} file={}",
478                    entry_no,
479                    input.display()
480                )
481            })?;
482        let ogg = extract_ovk_entry_from_bytes(&bytes, idx)?;
483        let wav_bytes = siglus_assets::vorbis::decode_ogg_vorbis_reader_to_wav(Cursor::new(ogg))
484            .with_context(|| format!("decode OVK(entry no={entry_no}) -> WAV"))?;
485        Ok(BgmDecoded {
486            container: BgmContainer::Ovk,
487            wav_bytes,
488            description: format!("OVK:{}#{}", input.display(), entry_no),
489        })
490    }
491    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
492    {
493        let pack =
494            ovk::OvkPack::open(input).with_context(|| format!("open OVK: {}", input.display()))?;
495        let idx = pack
496            .entries()
497            .iter()
498            .position(|e| e.no == entry_no)
499            .with_context(|| {
500                format!(
501                    "OVK entry not found: no={} file={}",
502                    entry_no,
503                    input.display()
504                )
505            })?;
506
507        let wav_bytes = pack
508            .decode_entry_vorbis_wav(idx)
509            .with_context(|| format!("decode OVK(entry no={entry_no}) -> WAV"))?;
510        Ok(BgmDecoded {
511            container: BgmContainer::Ovk,
512            wav_bytes,
513            description: format!("OVK:{}#{}", input.display(), entry_no),
514        })
515    }
516}
517
518/// Extract raw Ogg bytes from Siglus containers.
519///
520/// * `.ovk`: extracts the Ogg segment at `entry_idx`.
521/// * `.owp`: decrypts the whole file to raw Ogg.
522pub fn extract_ogg_bytes(
523    input: impl AsRef<Path>,
524    entry_idx: Option<usize>,
525) -> Result<(Vec<u8>, String)> {
526    let input = input.as_ref();
527    let kind = BgmContainer::from_path(input);
528
529    match kind {
530        BgmContainer::Ovk => {
531            let idx = entry_idx.unwrap_or(0);
532            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
533            {
534                let bytes = read_audio_container_bytes(input)?;
535                let ogg = extract_ovk_entry_from_bytes(&bytes, idx).context("extract OVK entry")?;
536                Ok((ogg, format!("OVK:{}[{}]", input.display(), idx)))
537            }
538            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
539            {
540                let pack = ovk::OvkPack::open(input)
541                    .with_context(|| format!("open OVK: {}", input.display()))?;
542                let entry_cnt = pack.entries().len();
543                if idx >= entry_cnt {
544                    bail!("OVK entry out of range: idx={} entries={}", idx, entry_cnt);
545                }
546                let ogg = pack.extract_entry(idx).context("extract OVK entry")?;
547                Ok((ogg, format!("OVK:{}[{}]", input.display(), idx)))
548            }
549        }
550        BgmContainer::Owp => {
551            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
552            {
553                let bytes = read_audio_container_bytes(input)?;
554                let ogg = decrypt_owp_bytes(bytes);
555                Ok((ogg, format!("OWP:{}", input.display())))
556            }
557            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
558            {
559                let owp = ovk::OwpFile::open(input)
560                    .with_context(|| format!("open OWP: {}", input.display()))?;
561                let ogg = owp.decrypt_to_vec().context("decrypt OWP -> Ogg")?;
562                Ok((ogg, format!("OWP:{}", input.display())))
563            }
564        }
565        BgmContainer::Ogg => {
566            let ogg = read_audio_container_bytes(input)?;
567            Ok((ogg, format!("OGG:{}", input.display())))
568        }
569        _ => bail!("container does not support Ogg extraction: {:?}", kind),
570    }
571}
572
573pub fn resolve_koe_source(project_dir: &Path, koe_no: i64) -> Result<KoeSource> {
574    if koe_no < 0 {
575        bail!("invalid koe number: {koe_no}");
576    }
577
578    let koe_no_u32 = koe_no as u32;
579    let scn_no = koe_no_u32 / 100_000;
580    let base = project_dir.join("koe");
581    for dir in [format!("{:04}", scn_no), scn_no.to_string()] {
582        for stem in [
583            format!("z{:09}", koe_no_u32),
584            format!("Z{:09}", koe_no_u32),
585            format!("z{}", koe_no_u32),
586            format!("Z{}", koe_no_u32),
587        ] {
588            for ext in ["wav", "nwa", "ogg"] {
589                let p = base.join(&dir).join(format!("{stem}.{ext}"));
590                if path_is_file(&p) {
591                    return Ok(KoeSource::File(p));
592                }
593            }
594        }
595    }
596
597    let ovk = base.join(format!("z{:04}.ovk", scn_no));
598    if path_is_file(&ovk) {
599        return Ok(KoeSource::OvkEntryByNo {
600            path: ovk,
601            entry_no: koe_no_u32 % 100_000,
602        });
603    }
604
605    bail!("koe resource not found: koe_no={koe_no}")
606}
607
608
609fn path_is_file(path: &Path) -> bool {
610    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
611    { crate::resource::wasm_path_is_file(path) }
612    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
613    { path.is_file() }
614}