Skip to main content

siglus_scene_vm/audio/
engine.rs

1use crate::platform_time::{Duration, Instant};
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use anyhow::{Context, Result, anyhow};
7#[cfg(target_arch = "wasm32")]
8use kira::sound::static_sound::{
9    StaticSoundData as BgmSoundData, StaticSoundHandle as BgmSoundHandle,
10};
11#[cfg(not(target_arch = "wasm32"))]
12type BgmSoundData = kira::sound::streaming::StreamingSoundData<kira::sound::FromFileError>;
13#[cfg(not(target_arch = "wasm32"))]
14type BgmSoundHandle = kira::sound::streaming::StreamingSoundHandle<kira::sound::FromFileError>;
15use kira::Volume;
16use kira::sound::{EndPosition, PlaybackPosition, Region};
17use kira::tween::Tween;
18use siglus_assets::gameexe::{GameexeConfig, GameexeDecodeOptions, decode_gameexe_dat_bytes};
19
20use super::bgm::{BgmPlaybackFormat, decode_bgm_to_playback_bytes};
21use super::{AudioHub, TrackKind};
22
23const TNM_BGM_START_POS_INI: i64 = -1;
24const TNM_BGM_PLAYER_CNT: usize = 2;
25
26#[derive(Debug, Clone)]
27struct WavPcmRegion {
28    data_offset: usize,
29    data_len: usize,
30    data_len_field_offset: usize,
31    riff_len_field_offset: usize,
32    byte_rate: u32,
33    block_align: usize,
34    sample_rate: u32,
35    channels: u16,
36    bits_per_sample: u16,
37    audio_format: u16,
38}
39
40impl WavPcmRegion {
41    fn sample_count(&self) -> u64 {
42        (self.data_len / self.block_align.max(1)) as u64
43    }
44}
45
46fn wav_pcm_region(wav: &[u8]) -> Option<WavPcmRegion> {
47    if wav.len() < 44 {
48        return None;
49    }
50    if &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" {
51        return None;
52    }
53
54    let mut pos = 12usize;
55    let mut byte_rate: Option<u32> = None;
56    let mut block_align: Option<usize> = None;
57    let mut sample_rate: Option<u32> = None;
58    let mut channels: Option<u16> = None;
59    let mut bits_per_sample: Option<u16> = None;
60    let mut audio_format: Option<u16> = None;
61    let mut data_offset: Option<usize> = None;
62    let mut data_len: Option<usize> = None;
63    let mut data_len_field_offset: Option<usize> = None;
64
65    while pos + 8 <= wav.len() {
66        let id = &wav[pos..pos + 4];
67        let sz =
68            u32::from_le_bytes([wav[pos + 4], wav[pos + 5], wav[pos + 6], wav[pos + 7]]) as usize;
69        let sz_field_off = pos + 4;
70        pos += 8;
71        if pos + sz > wav.len() {
72            break;
73        }
74        if id == b"fmt " {
75            if sz >= 16 {
76                audio_format = Some(u16::from_le_bytes([wav[pos], wav[pos + 1]]));
77                channels = Some(u16::from_le_bytes([wav[pos + 2], wav[pos + 3]]));
78                sample_rate = Some(u32::from_le_bytes([
79                    wav[pos + 4],
80                    wav[pos + 5],
81                    wav[pos + 6],
82                    wav[pos + 7],
83                ]));
84                byte_rate = Some(u32::from_le_bytes([
85                    wav[pos + 8],
86                    wav[pos + 9],
87                    wav[pos + 10],
88                    wav[pos + 11],
89                ]));
90                block_align = Some(u16::from_le_bytes([wav[pos + 12], wav[pos + 13]]) as usize);
91                bits_per_sample = Some(u16::from_le_bytes([wav[pos + 14], wav[pos + 15]]));
92            }
93        } else if id == b"data" {
94            data_offset = Some(pos);
95            data_len = Some(sz);
96            data_len_field_offset = Some(sz_field_off);
97        }
98        pos += sz;
99        if (sz & 1) != 0 {
100            pos += 1;
101        }
102    }
103
104    Some(WavPcmRegion {
105        data_offset: data_offset?,
106        data_len: data_len?,
107        data_len_field_offset: data_len_field_offset?,
108        riff_len_field_offset: 4,
109        byte_rate: byte_rate?,
110        block_align: block_align?.max(1),
111        sample_rate: sample_rate?.max(1),
112        channels: channels?.max(1),
113        bits_per_sample: bits_per_sample?,
114        audio_format: audio_format?,
115    })
116}
117
118fn wav_slice_samples(wav: &[u8], start_sample: u64, end_sample: Option<u64>) -> Option<Vec<u8>> {
119    let region = wav_pcm_region(wav)?;
120    let total_samples = region.sample_count();
121    let start_sample = start_sample.min(total_samples);
122    let end_sample = end_sample
123        .unwrap_or(total_samples)
124        .min(total_samples)
125        .max(start_sample);
126
127    let start_byte = (start_sample as usize) * region.block_align;
128    let end_byte = (end_sample as usize) * region.block_align;
129    let src_begin = region.data_offset + start_byte;
130    let src_end = region.data_offset + end_byte;
131    if src_begin > wav.len() || src_end > wav.len() || src_begin > src_end {
132        return None;
133    }
134
135    let mut out = wav.to_vec();
136    out.splice(
137        region.data_offset..region.data_offset + region.data_len,
138        wav[src_begin..src_end].iter().copied(),
139    );
140    let new_data_len = (src_end - src_begin) as u32;
141    out[region.data_len_field_offset..region.data_len_field_offset + 4]
142        .copy_from_slice(&new_data_len.to_le_bytes());
143    let riff_len = (out.len().saturating_sub(8)) as u32;
144    out[region.riff_len_field_offset..region.riff_len_field_offset + 4]
145        .copy_from_slice(&riff_len.to_le_bytes());
146    Some(out)
147}
148
149fn parse_i64_like(s: &str) -> Option<i64> {
150    let s = s.trim();
151    if s.is_empty() {
152        return None;
153    }
154    if let Ok(v) = s.parse::<i64>() {
155        return Some(v);
156    }
157    if let Some(rest) = s.strip_prefix("0x") {
158        return i64::from_str_radix(rest, 16).ok();
159    }
160    if let Some(rest) = s.strip_prefix("-0x") {
161        return i64::from_str_radix(rest, 16).ok().map(|v| -v);
162    }
163    None
164}
165
166fn normalize_regist_name(name: &str) -> String {
167    name.trim().to_ascii_lowercase()
168}
169
170fn clamp_sample_range(
171    total_samples: u64,
172    start_sample: i64,
173    end_sample: i64,
174    restart_sample: i64,
175) -> (u64, u64, u64) {
176    let total_samples_i64 = total_samples as i64;
177    let end_sample = if end_sample < 0 {
178        total_samples_i64
179    } else {
180        end_sample.clamp(0, total_samples_i64)
181    };
182    let start_sample = start_sample.clamp(0, end_sample);
183    let restart_sample = restart_sample.clamp(0, end_sample);
184    (
185        start_sample as u64,
186        end_sample as u64,
187        restart_sample as u64,
188    )
189}
190
191fn tween_ms(ms: i64) -> Tween {
192    if ms > 0 {
193        Tween {
194            duration: Duration::from_millis(ms as u64),
195            ..Tween::default()
196        }
197    } else {
198        Tween::default()
199    }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203enum PendingBgmActionKind {
204    Stop,
205    Pause,
206}
207
208#[derive(Debug, Clone, Copy)]
209struct PendingBgmAction {
210    kind: PendingBgmActionKind,
211    at: Instant,
212}
213
214pub const TNM_PLAYER_STATE_FREE: i32 = 0;
215pub const TNM_PLAYER_STATE_PLAY: i32 = 1;
216pub const TNM_PLAYER_STATE_FADE_OUT: i32 = 2;
217pub const TNM_PLAYER_STATE_PAUSE: i32 = 3;
218
219#[derive(Debug, Clone)]
220struct BgmScriptEntry {
221    file_name: String,
222    start_sample: i64,
223    end_sample: i64,
224    repeat_sample: i64,
225}
226
227#[derive(Debug, Default)]
228struct BgmPlayerSlot {
229    handle: Option<BgmSoundHandle>,
230    source_bytes: Option<Arc<[u8]>>,
231    /// The file an `NwaStream` source decodes from while playing.
232    source_path: Option<PathBuf>,
233    source_format: Option<BgmPlaybackFormat>,
234    sample_rate_hz: u32,
235    total_samples: u64,
236    start_sample: u64,
237    end_sample: u64,
238    restart_sample: u64,
239    current_segment_start_sample: u64,
240    current_segment_samples: u64,
241    start_time: Option<Instant>,
242    paused_at: Option<Instant>,
243    paused_total: Duration,
244    pending: Option<PendingBgmAction>,
245    fade_outing: bool,
246    loop_flag: bool,
247    name: Option<String>,
248    file_name: Option<String>,
249    ready_only: bool,
250}
251
252impl BgmPlayerSlot {
253    fn reset_all(&mut self) {
254        if let Some(mut h) = self.handle.take() {
255            h.stop(Tween::default());
256        }
257        self.source_bytes = None;
258        self.source_path = None;
259        self.source_format = None;
260        self.sample_rate_hz = 0;
261        self.total_samples = 0;
262        self.start_sample = 0;
263        self.end_sample = 0;
264        self.restart_sample = 0;
265        self.current_segment_start_sample = 0;
266        self.current_segment_samples = 0;
267        self.start_time = None;
268        self.paused_at = None;
269        self.paused_total = Duration::from_millis(0);
270        self.pending = None;
271        self.fade_outing = false;
272        self.loop_flag = false;
273        self.name = None;
274        self.file_name = None;
275        self.ready_only = false;
276    }
277
278    fn clear_runtime_only(&mut self) {
279        self.handle = None;
280        self.current_segment_start_sample = self.start_sample;
281        self.current_segment_samples = self.end_sample.saturating_sub(self.start_sample);
282        self.start_time = None;
283        self.paused_at = None;
284        self.paused_total = Duration::from_millis(0);
285        self.pending = None;
286        self.fade_outing = false;
287        self.ready_only = false;
288    }
289
290    fn elapsed_ms(&self) -> u64 {
291        let Some(start) = self.start_time else {
292            return 0;
293        };
294        let now = self.paused_at.unwrap_or_else(Instant::now);
295        now.saturating_duration_since(start)
296            .saturating_sub(self.paused_total)
297            .as_millis() as u64
298    }
299
300    fn elapsed_samples(&self) -> u64 {
301        if self.sample_rate_hz == 0 {
302            return 0;
303        }
304        self.elapsed_ms().saturating_mul(self.sample_rate_hz as u64) / 1000
305    }
306
307    fn playback_window_samples(&self) -> u64 {
308        self.end_sample.saturating_sub(self.start_sample)
309    }
310
311    fn loop_span_samples(&self) -> u64 {
312        self.end_sample.saturating_sub(self.restart_sample)
313    }
314
315    fn has_loop_region(&self) -> bool {
316        self.loop_flag && self.restart_sample < self.end_sample
317    }
318
319    fn playback_data(
320        &self,
321        effective_start: u64,
322        amp: f64,
323        fade_in_ms: i64,
324    ) -> Result<BgmSoundData> {
325        // The original player owns a C_sound_stream and decodes while playing.
326        // Keep native playback streaming as well; the browser retains Kira's
327        // static fallback because kira::sound::streaming is unavailable there.
328        #[cfg(not(target_arch = "wasm32"))]
329        let data = if let Some(path) = self
330            .source_path
331            .as_ref()
332            .filter(|_| self.source_format == Some(BgmPlaybackFormat::NwaStream))
333        {
334            BgmSoundData::from_decoder(
335                super::nwa_stream::NwaDecoder::open(path)
336                    .with_context(|| format!("open NWA stream: {}", path.display()))?,
337            )
338        } else {
339            let source = self
340                .source_bytes
341                .as_ref()
342                .context("BGM slot not prepared")?;
343            BgmSoundData::from_cursor(Cursor::new(Arc::clone(source)))
344                .context("kira: prepare BGM playback stream")?
345        };
346        #[cfg(target_arch = "wasm32")]
347        let data = {
348            let source = self
349                .source_bytes
350                .as_ref()
351                .context("BGM slot not prepared")?;
352            BgmSoundData::from_cursor(Cursor::new(Arc::clone(source)))
353                .context("kira: prepare BGM playback stream")?
354        };
355        let mut data = data
356            .start_position(PlaybackPosition::Samples(effective_start as usize))
357            .slice(Region {
358                start: PlaybackPosition::Samples(0),
359                end: EndPosition::Custom(PlaybackPosition::Samples(self.end_sample as usize)),
360            })
361            .volume(Volume::Amplitude(amp));
362        if self.has_loop_region() {
363            data = data.loop_region(Region {
364                start: PlaybackPosition::Samples(self.restart_sample as usize),
365                end: EndPosition::Custom(PlaybackPosition::Samples(self.end_sample as usize)),
366            });
367        }
368        if fade_in_ms > 0 {
369            data = data.fade_in_tween(tween_ms(fade_in_ms));
370        }
371        Ok(data)
372    }
373
374    fn play_pos_samples(&self) -> u64 {
375        let elapsed = self.elapsed_samples();
376        if self.has_loop_region() {
377            let intro_samples = self.restart_sample.saturating_sub(self.start_sample);
378            let loop_span = self.loop_span_samples();
379            if loop_span == 0 {
380                return self.end_sample;
381            }
382            if self.start_sample < self.restart_sample && elapsed < intro_samples {
383                return self.start_sample + elapsed.min(intro_samples);
384            }
385            let loop_elapsed = if self.start_sample < self.restart_sample {
386                elapsed.saturating_sub(intro_samples)
387            } else {
388                elapsed
389            };
390            return self.restart_sample + (loop_elapsed % loop_span);
391        }
392        self.start_sample + elapsed.min(self.playback_window_samples())
393    }
394
395    fn check_state(&self) -> i32 {
396        if self.handle.is_none() {
397            return TNM_PLAYER_STATE_FREE;
398        }
399        if self.paused_at.is_some() {
400            return TNM_PLAYER_STATE_PAUSE;
401        }
402        if self.fade_outing {
403            return TNM_PLAYER_STATE_FADE_OUT;
404        }
405        TNM_PLAYER_STATE_PLAY
406    }
407
408    fn is_playing(&self) -> bool {
409        self.handle.is_some() && !self.fade_outing && self.paused_at.is_none()
410    }
411}
412
413pub struct BgmEngine {
414    project_dir: PathBuf,
415    current_append_dir: String,
416    game_volume_raw: u8,
417    system_volume_raw: u8,
418    current_name: Option<String>,
419    current_player_id: Option<usize>,
420    players: Vec<BgmPlayerSlot>,
421    retired: Vec<(BgmSoundHandle, Instant)>,
422    delay_deadline: Option<Instant>,
423    delayed_fade_in_ms: i64,
424    loop_flag: bool,
425    pause_flag: bool,
426}
427
428impl std::fmt::Debug for BgmEngine {
429    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430        f.debug_struct("BgmEngine")
431            .field("game_volume_raw", &self.game_volume_raw)
432            .field("system_volume_raw", &self.system_volume_raw)
433            .field("current_name", &self.current_name)
434            .field("current_player_id", &self.current_player_id)
435            .field("loop_flag", &self.loop_flag)
436            .field("pause_flag", &self.pause_flag)
437            .finish()
438    }
439}
440
441impl Drop for BgmEngine {
442    fn drop(&mut self) {
443        // Native streaming decoders can outlive their handles. Stop them while
444        // CommandContext still owns the AudioHub that receives stop commands.
445        let immediate = Tween {
446            duration: Duration::ZERO,
447            ..Tween::default()
448        };
449        for slot in &mut self.players {
450            if let Some(handle) = slot.handle.as_mut() {
451                handle.stop(immediate);
452            }
453        }
454        for (handle, _) in &mut self.retired {
455            handle.stop(immediate);
456        }
457    }
458}
459
460impl BgmEngine {
461    pub fn new(project_dir: PathBuf) -> Self {
462        Self {
463            project_dir,
464            current_append_dir: String::new(),
465            game_volume_raw: 255,
466            system_volume_raw: 255,
467            current_name: None,
468            current_player_id: None,
469            players: (0..TNM_BGM_PLAYER_CNT)
470                .map(|_| BgmPlayerSlot::default())
471                .collect(),
472            retired: Vec::new(),
473            delay_deadline: None,
474            delayed_fade_in_ms: 0,
475            loop_flag: false,
476            pause_flag: false,
477        }
478    }
479
480    pub fn current_name(&self) -> Option<&str> {
481        self.current_name.as_deref()
482    }
483
484    /// Bytes currently retained directly by BGM player slots.  This is a
485    /// bookkeeping query only; it does not touch Kira or decode audio.  Shared
486    /// Arc payloads are counted once.
487    pub fn debug_source_memory(&self) -> (usize, usize) {
488        let mut seen = [std::ptr::null::<u8>(); TNM_BGM_PLAYER_CNT];
489        let mut seen_len = 0usize;
490        let mut bytes = 0usize;
491        let mut slots = 0usize;
492        for slot in &self.players {
493            let Some(source) = slot.source_bytes.as_ref() else {
494                continue;
495            };
496            slots += 1;
497            let ptr = source.as_ptr();
498            if seen[..seen_len].contains(&ptr) {
499                continue;
500            }
501            if seen_len < seen.len() {
502                seen[seen_len] = ptr;
503                seen_len += 1;
504            }
505            bytes = bytes.saturating_add(source.len());
506        }
507        (bytes, slots)
508    }
509
510    pub fn set_current_append_dir(&mut self, append_dir: impl Into<String>) {
511        let append_dir = append_dir.into();
512        if self.current_append_dir != append_dir {
513            self.current_append_dir = append_dir;
514        }
515    }
516
517    pub fn set_current_append_dir_ref(&mut self, append_dir: &str) {
518        if self.current_append_dir != append_dir {
519            self.current_append_dir.clear();
520            self.current_append_dir.push_str(append_dir);
521        }
522    }
523
524    pub fn volume_raw(&self) -> u8 {
525        self.game_volume_raw
526    }
527    pub fn save_loop_flag(&self) -> bool {
528        self.loop_flag
529    }
530
531    pub fn save_pause_flag(&self) -> bool {
532        self.pause_flag
533    }
534
535    pub fn save_delay_time_ms(&self) -> i64 {
536        self.delay_deadline
537            .map(|deadline| {
538                deadline
539                    .saturating_duration_since(Instant::now())
540                    .as_millis()
541                    .min(i64::MAX as u128) as i64
542            })
543            .unwrap_or(0)
544    }
545
546    fn total_gain_amplitude(&self) -> f64 {
547        (self.game_volume_raw as f64 / 255.0) * (self.system_volume_raw as f64 / 255.0)
548    }
549
550    fn apply_all_active_volumes(&mut self, fade_ms: i64) {
551        let amp = self.total_gain_amplitude();
552        let tween = tween_ms(fade_ms);
553        for slot in &mut self.players {
554            if let Some(h) = &mut slot.handle {
555                h.set_volume(Volume::Amplitude(amp), tween);
556            }
557        }
558    }
559
560    pub fn set_volume_raw(&mut self, _audio: &mut AudioHub, volume_raw: u8) -> Result<()> {
561        self.game_volume_raw = volume_raw;
562        self.apply_all_active_volumes(0);
563        Ok(())
564    }
565
566    pub fn set_volume_raw_fade(
567        &mut self,
568        _audio: &mut AudioHub,
569        volume_raw: u8,
570        fade_ms: i64,
571    ) -> Result<()> {
572        self.game_volume_raw = volume_raw;
573        self.apply_all_active_volumes(fade_ms);
574        Ok(())
575    }
576
577    pub fn set_system_volume_raw(&mut self, volume_raw: u8) {
578        self.system_volume_raw = volume_raw;
579        self.apply_all_active_volumes(0);
580    }
581
582    pub fn set_looping(&mut self, looping: bool) -> Result<()> {
583        self.loop_flag = looping;
584        Ok(())
585    }
586
587    pub fn check_state(&self) -> i32 {
588        self.current_slot()
589            .map(|slot| slot.check_state())
590            .unwrap_or(TNM_PLAYER_STATE_FREE)
591    }
592
593    pub fn is_playing(&self) -> bool {
594        self.current_slot()
595            .map(|slot| slot.is_playing())
596            .unwrap_or(false)
597    }
598
599    pub fn is_fade_out_doing(&self) -> bool {
600        self.check_state() == TNM_PLAYER_STATE_FADE_OUT
601    }
602
603    pub fn can_wait(&self) -> bool {
604        self.is_playing() && !self.loop_flag
605    }
606
607    fn current_slot(&self) -> Option<&BgmPlayerSlot> {
608        self.current_player_id.and_then(|id| self.players.get(id))
609    }
610
611    fn current_slot_mut(&mut self) -> Option<&mut BgmPlayerSlot> {
612        self.current_player_id
613            .and_then(|id| self.players.get_mut(id))
614    }
615
616    fn lookup_gameexe_bgm_entry(&self, regist_name: &str) -> Option<BgmScriptEntry> {
617        let cfg = load_gameexe_config(&self.project_dir)?;
618        let target = normalize_regist_name(regist_name);
619        let cnt = cfg.indexed_count("BGM");
620        for i in 0..cnt {
621            let Some(key_name) = cfg.get_indexed_item_unquoted("BGM", i, 0) else {
622                continue;
623            };
624            if normalize_regist_name(key_name) != target {
625                continue;
626            }
627            let file_name = cfg
628                .get_indexed_item_unquoted("BGM", i, 1)?
629                .trim()
630                .to_string();
631            if file_name.is_empty() {
632                continue;
633            }
634            let start_sample = cfg
635                .get_indexed_item_unquoted("BGM", i, 2)
636                .and_then(parse_i64_like)
637                .unwrap_or(0);
638            let end_sample = cfg
639                .get_indexed_item_unquoted("BGM", i, 3)
640                .and_then(parse_i64_like)
641                .unwrap_or(-1);
642            let repeat_sample = cfg
643                .get_indexed_item_unquoted("BGM", i, 4)
644                .and_then(parse_i64_like)
645                .unwrap_or(0);
646            return Some(BgmScriptEntry {
647                file_name,
648                start_sample,
649                end_sample,
650                repeat_sample,
651            });
652        }
653        None
654    }
655
656    fn resolve_bgm_script(&self, regist_name: &str) -> Result<(BgmScriptEntry, PathBuf)> {
657        if let Some(entry) = self.lookup_gameexe_bgm_entry(regist_name) {
658            let (path, _ty) = crate::resource::find_audio_path_with_append_dir(
659                &self.project_dir,
660                &self.current_append_dir,
661                "bgm",
662                &entry.file_name,
663            )
664            .map_err(|err| {
665                anyhow!(
666                    "BGM file not found for regist name {}: {}; {}",
667                    regist_name,
668                    entry.file_name,
669                    err
670                )
671            })?;
672            return Ok((entry, path));
673        }
674
675        let direct_name = regist_name.trim();
676        let (path, _ty) = crate::resource::find_audio_path_with_append_dir(
677            &self.project_dir,
678            &self.current_append_dir,
679            "bgm",
680            direct_name,
681        )
682        .map_err(|err| {
683            anyhow!("BGM regist name not found in script table: {regist_name}; {err}")
684        })?;
685        Ok((
686            BgmScriptEntry {
687                file_name: direct_name.to_string(),
688                start_sample: 0,
689                end_sample: -1,
690                repeat_sample: 0,
691            },
692            path,
693        ))
694    }
695
696    fn prepare_slot(
697        &mut self,
698        slot_id: usize,
699        regist_name: &str,
700        loop_flag: bool,
701        start_pos_sample: i64,
702        ready_only: bool,
703    ) -> Result<()> {
704        let (script_entry, path) = self.resolve_bgm_script(regist_name)?;
705
706        let decoded = decode_bgm_to_playback_bytes(&path, None)
707            .with_context(|| format!("prepare BGM playback: {}", path.display()))?;
708
709        let total_samples = decoded.total_samples;
710
711        let script_start = script_entry.start_sample;
712        let script_end = script_entry.end_sample;
713        let script_repeat = script_entry.repeat_sample;
714        let effective_start = if start_pos_sample == TNM_BGM_START_POS_INI {
715            script_start
716        } else {
717            start_pos_sample
718        };
719        let (start_sample, end_sample, restart_sample) =
720            clamp_sample_range(total_samples, effective_start, script_end, script_repeat);
721
722        if env_is_set!("SG_AUDIO_TRACE") {
723            eprintln!(
724                "[SG_AUDIO_TRACE] bgm.prepare name={} file={} source_format={:?} container={:?} channels={} sample_rate={} total_frames={} start={} end={} repeat={}",
725                regist_name,
726                path.display(),
727                decoded.format,
728                decoded.container,
729                decoded.channels,
730                decoded.sample_rate,
731                total_samples,
732                start_sample,
733                end_sample,
734                restart_sample
735            );
736        }
737
738        let slot = &mut self.players[slot_id];
739        slot.reset_all();
740        slot.source_bytes = (decoded.stream_path.is_none()).then(|| decoded.bytes.into());
741        slot.source_path = decoded.stream_path;
742        slot.source_format = Some(decoded.format);
743        slot.sample_rate_hz = decoded.sample_rate;
744        slot.total_samples = total_samples;
745        slot.start_sample = start_sample;
746        slot.end_sample = end_sample;
747        slot.restart_sample = restart_sample;
748        slot.current_segment_start_sample = start_sample;
749        slot.current_segment_samples = end_sample.saturating_sub(start_sample);
750        slot.loop_flag = loop_flag;
751        slot.name = Some(regist_name.to_string());
752        slot.file_name = Some(path.to_string_lossy().to_string());
753        slot.ready_only = ready_only;
754        Ok(())
755    }
756
757    fn start_slot_internal(
758        &mut self,
759        audio: &mut AudioHub,
760        slot_id: usize,
761        fade_in_ms: i64,
762        start_paused: bool,
763    ) -> Result<()> {
764        let amp = self.total_gain_amplitude();
765        let slot = &mut self.players[slot_id];
766        if slot.source_bytes.is_none() && slot.source_path.is_none() {
767            return Err(anyhow!("BGM slot not prepared"));
768        }
769
770        let mut effective_start = slot.start_sample;
771        if effective_start >= slot.end_sample {
772            if slot.has_loop_region() {
773                effective_start = slot.restart_sample;
774            } else {
775                return Err(anyhow!("invalid BGM range: start >= end"));
776            }
777        }
778        if effective_start >= slot.end_sample {
779            return Err(anyhow!("invalid BGM range after restart clamp"));
780        }
781
782        if !audio.is_enabled() {
783            slot.handle = None;
784            slot.current_segment_start_sample = effective_start;
785            slot.current_segment_samples = slot.end_sample.saturating_sub(effective_start);
786            slot.start_time = Some(Instant::now());
787            slot.paused_at = if start_paused { slot.start_time } else { None };
788            slot.paused_total = Duration::from_millis(0);
789            slot.pending = None;
790            slot.fade_outing = false;
791            slot.start_sample = effective_start;
792            slot.ready_only = start_paused;
793            return Ok(());
794        }
795
796        let data = slot.playback_data(
797            effective_start,
798            amp,
799            if start_paused { 0 } else { fade_in_ms },
800        )?;
801        #[cfg(not(target_arch = "wasm32"))]
802        let mut handle = audio.play_streaming(TrackKind::Bgm, data)?;
803        #[cfg(target_arch = "wasm32")]
804        let mut handle = audio.play_static(TrackKind::Bgm, data)?;
805
806        if start_paused {
807            handle.pause(Tween::default());
808        }
809
810        slot.handle = Some(handle);
811        slot.current_segment_start_sample = effective_start;
812        slot.current_segment_samples = slot.end_sample.saturating_sub(effective_start);
813        slot.start_time = Some(Instant::now());
814        slot.paused_at = if start_paused { slot.start_time } else { None };
815        slot.paused_total = Duration::from_millis(0);
816        slot.pending = None;
817        slot.fade_outing = false;
818        slot.start_sample = effective_start;
819        slot.ready_only = start_paused;
820        Ok(())
821    }
822
823    fn start_slot(&mut self, audio: &mut AudioHub, slot_id: usize, fade_in_ms: i64) -> Result<()> {
824        self.start_slot_internal(audio, slot_id, fade_in_ms, false)
825    }
826
827    fn ready_slot(&mut self, audio: &mut AudioHub, slot_id: usize) -> Result<()> {
828        self.start_slot_internal(audio, slot_id, 0, true)
829    }
830
831    fn handoff_current_to_retired(&mut self, fade_out_ms: i64) {
832        let Some(cur_id) = self.current_player_id else {
833            return;
834        };
835        let slot = &mut self.players[cur_id];
836        if let Some(mut h) = slot.handle.take() {
837            if fade_out_ms > 0 {
838                h.stop(tween_ms(fade_out_ms));
839                self.retired.push((
840                    h,
841                    Instant::now() + Duration::from_millis(fade_out_ms as u64),
842                ));
843            } else {
844                h.stop(Tween::default());
845            }
846        }
847        slot.clear_runtime_only();
848    }
849
850    pub fn play_name_script(
851        &mut self,
852        audio: &mut AudioHub,
853        name: &str,
854        loop_flag: bool,
855        fade_in_ms: i64,
856        fade_out_ms: i64,
857        start_pos_sample: i64,
858        ready_only: bool,
859        delay_time_ms: i64,
860    ) -> Result<()> {
861        let regist_name = normalize_regist_name(name);
862        if self.current_name.as_deref() == Some(regist_name.as_str()) && self.loop_flag && loop_flag
863        {
864            return Ok(());
865        }
866
867        self.handoff_current_to_retired(fade_out_ms);
868        let next_id = match self.current_player_id {
869            Some(id) => (id + 1) % TNM_BGM_PLAYER_CNT,
870            None => 0,
871        };
872        let total_ready_only = ready_only || delay_time_ms > 0;
873        self.prepare_slot(
874            next_id,
875            &regist_name,
876            loop_flag,
877            start_pos_sample,
878            total_ready_only,
879        )?;
880        self.current_player_id = Some(next_id);
881        self.current_name = Some(regist_name);
882        self.loop_flag = loop_flag;
883        self.pause_flag = ready_only;
884        self.delayed_fade_in_ms = fade_in_ms;
885        self.delay_deadline = if delay_time_ms > 0 {
886            Some(Instant::now() + Duration::from_millis(delay_time_ms.max(0) as u64))
887        } else {
888            None
889        };
890
891        if total_ready_only {
892            self.ready_slot(audio, next_id)?;
893        } else {
894            self.start_slot(audio, next_id, fade_in_ms)?;
895        }
896        Ok(())
897    }
898
899    pub fn ready_name(
900        &mut self,
901        audio: &mut AudioHub,
902        name: &str,
903        start_pos_sample: i64,
904    ) -> Result<()> {
905        self.play_name_script(audio, name, self.loop_flag, 0, 0, start_pos_sample, true, 0)
906    }
907
908    pub fn play_name_with_options(
909        &mut self,
910        audio: &mut AudioHub,
911        name: &str,
912        start_pos_sample: i64,
913        fade_in_ms: i64,
914    ) -> Result<()> {
915        self.play_name_script(
916            audio,
917            name,
918            self.loop_flag,
919            fade_in_ms,
920            0,
921            start_pos_sample,
922            false,
923            0,
924        )
925    }
926
927    pub fn play_name(&mut self, audio: &mut AudioHub, name: &str) -> Result<()> {
928        self.play_name_script(audio, name, true, 0, 0, TNM_BGM_START_POS_INI, false, 0)
929    }
930
931    pub fn play_pos_samples(&self) -> u64 {
932        self.current_slot()
933            .map(|s| s.play_pos_samples())
934            .unwrap_or(0)
935    }
936
937    pub fn pause_fade(&mut self, _audio: &mut AudioHub, fade_ms: i64) -> Result<()> {
938        self.delay_deadline = None;
939        self.pause_flag = true;
940        let amp = self.total_gain_amplitude();
941        let Some(slot) = self.current_slot_mut() else {
942            return Ok(());
943        };
944        if slot.handle.is_none() || slot.paused_at.is_some() {
945            return Ok(());
946        }
947        if fade_ms > 0 {
948            if let Some(h) = &mut slot.handle {
949                h.set_volume(Volume::Amplitude(amp), Tween::default());
950                h.set_volume(Volume::Amplitude(0.0), tween_ms(fade_ms));
951            }
952            slot.fade_outing = true;
953            slot.pending = Some(PendingBgmAction {
954                kind: PendingBgmActionKind::Pause,
955                at: Instant::now() + Duration::from_millis(fade_ms as u64),
956            });
957        } else {
958            if let Some(h) = &mut slot.handle {
959                h.pause(Tween::default());
960            }
961            slot.paused_at = Some(Instant::now());
962        }
963        Ok(())
964    }
965
966    pub fn pause(&mut self) -> Result<()> {
967        self.delay_deadline = None;
968        self.pause_flag = true;
969        let Some(slot) = self.current_slot_mut() else {
970            return Ok(());
971        };
972        if slot.handle.is_none() || slot.paused_at.is_some() {
973            return Ok(());
974        }
975        if let Some(h) = &mut slot.handle {
976            h.pause(Tween::default());
977        }
978        slot.paused_at = Some(Instant::now());
979        Ok(())
980    }
981
982    pub fn resume_script(
983        &mut self,
984        audio: &mut AudioHub,
985        fade_in_ms: i64,
986        delay_time_ms: i64,
987    ) -> Result<()> {
988        if delay_time_ms > 0 {
989            self.delay_deadline =
990                Some(Instant::now() + Duration::from_millis(delay_time_ms as u64));
991            self.delayed_fade_in_ms = fade_in_ms;
992            self.pause_flag = false;
993            return Ok(());
994        }
995
996        let amp = self.total_gain_amplitude();
997        let Some(cur_id) = self.current_player_id else {
998            return Ok(());
999        };
1000        if self.players[cur_id].handle.is_none() {
1001            self.start_slot(audio, cur_id, fade_in_ms)?;
1002            self.pause_flag = false;
1003            self.delay_deadline = None;
1004            return Ok(());
1005        }
1006
1007        let slot = &mut self.players[cur_id];
1008        if let Some(p) = slot.paused_at.take() {
1009            slot.paused_total += Instant::now().saturating_duration_since(p);
1010        }
1011        if let Some(h) = &mut slot.handle {
1012            h.resume(Tween::default());
1013            if fade_in_ms > 0 {
1014                h.set_volume(Volume::Amplitude(0.0), Tween::default());
1015                h.set_volume(Volume::Amplitude(amp), tween_ms(fade_in_ms));
1016            } else {
1017                h.set_volume(Volume::Amplitude(amp), Tween::default());
1018            }
1019        }
1020        slot.fade_outing = false;
1021        slot.pending = None;
1022        slot.ready_only = false;
1023        self.pause_flag = false;
1024        self.delay_deadline = None;
1025        Ok(())
1026    }
1027
1028    pub fn resume_fade(&mut self, audio: &mut AudioHub, fade_ms: i64) -> Result<()> {
1029        self.resume_script(audio, fade_ms, 0)
1030    }
1031
1032    pub fn resume(&mut self) -> Result<()> {
1033        let amp = self.total_gain_amplitude();
1034        let Some(slot) = self.current_slot_mut() else {
1035            return Ok(());
1036        };
1037        if let Some(p) = slot.paused_at.take() {
1038            slot.paused_total += Instant::now().saturating_duration_since(p);
1039            if let Some(h) = &mut slot.handle {
1040                h.resume(Tween::default());
1041                h.set_volume(Volume::Amplitude(amp), Tween::default());
1042            }
1043        }
1044        slot.ready_only = false;
1045        self.pause_flag = false;
1046        Ok(())
1047    }
1048
1049    pub fn stop(&mut self) -> Result<()> {
1050        self.stop_current_internal(0)
1051    }
1052
1053    fn stop_current_internal(&mut self, fade_out_ms: i64) -> Result<()> {
1054        self.delay_deadline = None;
1055        if let Some(slot) = self.current_slot_mut() {
1056            if slot.handle.is_none() {
1057                slot.clear_runtime_only();
1058            } else if fade_out_ms > 0 {
1059                if let Some(h) = &mut slot.handle {
1060                    h.stop(tween_ms(fade_out_ms));
1061                }
1062                slot.fade_outing = true;
1063                slot.pending = Some(PendingBgmAction {
1064                    kind: PendingBgmActionKind::Stop,
1065                    at: Instant::now() + Duration::from_millis(fade_out_ms as u64),
1066                });
1067            } else {
1068                if let Some(mut h) = slot.handle.take() {
1069                    h.stop(Tween::default());
1070                }
1071                slot.clear_runtime_only();
1072            }
1073        }
1074        self.current_name = None;
1075        self.loop_flag = false;
1076        self.pause_flag = false;
1077        Ok(())
1078    }
1079
1080    pub fn stop_fade(&mut self, fade_out_ms: i64) -> Result<()> {
1081        self.stop_current_internal(fade_out_ms)
1082    }
1083
1084    pub fn tick(&mut self, audio: &mut AudioHub) -> Result<()> {
1085        let now = Instant::now();
1086        self.retired.retain_mut(|(h, deadline)| {
1087            if now >= *deadline {
1088                h.stop(Tween::default());
1089                false
1090            } else {
1091                true
1092            }
1093        });
1094
1095        if let Some(deadline) = self.delay_deadline
1096            && now >= deadline
1097        {
1098            self.delay_deadline = None;
1099            self.resume_script(audio, self.delayed_fade_in_ms, 0)?;
1100        }
1101
1102        let Some(cur_id) = self.current_player_id else {
1103            return Ok(());
1104        };
1105
1106        #[cfg(not(target_arch = "wasm32"))]
1107        if let Some(err) = self.players[cur_id]
1108            .handle
1109            .as_mut()
1110            .and_then(|h| h.pop_error())
1111        {
1112            self.stop_current_internal(0)?;
1113            return Err(err).context("kira: decode BGM stream");
1114        }
1115
1116        let pending = self.players[cur_id].pending;
1117        if let Some(pending) = pending
1118            && now >= pending.at
1119        {
1120            let slot = &mut self.players[cur_id];
1121            slot.pending = None;
1122            match pending.kind {
1123                PendingBgmActionKind::Stop => {
1124                    if let Some(mut h) = slot.handle.take() {
1125                        h.stop(Tween::default());
1126                    }
1127                    slot.clear_runtime_only();
1128                }
1129                PendingBgmActionKind::Pause => {
1130                    if let Some(h) = &mut slot.handle {
1131                        h.pause(Tween::default());
1132                    }
1133                    slot.paused_at = Some(now);
1134                    slot.fade_outing = false;
1135                }
1136            }
1137        }
1138
1139        let (paused, has_handle, segment_samples, elapsed_samples, has_loop_region) = {
1140            let slot = &self.players[cur_id];
1141            (
1142                slot.paused_at.is_some(),
1143                slot.handle.is_some(),
1144                slot.playback_window_samples(),
1145                slot.elapsed_samples(),
1146                slot.has_loop_region(),
1147            )
1148        };
1149        if paused || !has_handle {
1150            return Ok(());
1151        }
1152        if segment_samples == 0 {
1153            return Ok(());
1154        }
1155        if elapsed_samples < segment_samples {
1156            return Ok(());
1157        }
1158        if has_loop_region {
1159            return Ok(());
1160        } else {
1161            let slot = &mut self.players[cur_id];
1162            if let Some(mut h) = slot.handle.take() {
1163                h.stop(Tween::default());
1164            }
1165            slot.clear_runtime_only();
1166        }
1167        Ok(())
1168    }
1169}
1170
1171fn path_is_file(path: &Path) -> bool {
1172    crate::resource::game_file_exists(path)
1173}
1174
1175fn load_gameexe_decode_options(project_dir: &Path) -> Result<GameexeDecodeOptions> {
1176    crate::resource::load_gameexe_decode_options(project_dir)
1177}
1178
1179fn find_gameexe_path(project_dir: &Path) -> Option<PathBuf> {
1180    const CANDIDATES: &[&str] = &[
1181        "Gameexe.dat",
1182        "Gameexe.ini",
1183        "gameexe.dat",
1184        "gameexe.ini",
1185        "GameexeEN.dat",
1186        "GameexeEN.ini",
1187        "GameexeZH.dat",
1188        "GameexeZH.ini",
1189        "GameexeZHTW.dat",
1190        "GameexeZHTW.ini",
1191        "GameexeDE.dat",
1192        "GameexeDE.ini",
1193        "GameexeES.dat",
1194        "GameexeES.ini",
1195        "GameexeFR.dat",
1196        "GameexeFR.ini",
1197        "GameexeID.dat",
1198        "GameexeID.ini",
1199    ];
1200    for name in CANDIDATES {
1201        let p = project_dir.join(name);
1202        if path_is_file(&p) {
1203            return Some(p);
1204        }
1205    }
1206    None
1207}
1208
1209fn load_gameexe_config(project_dir: &Path) -> Option<GameexeConfig> {
1210    let path = find_gameexe_path(project_dir)?;
1211    let raw = crate::resource::read_file_bytes(&path).ok()?;
1212    if path
1213        .extension()
1214        .and_then(|s| s.to_str())
1215        .is_some_and(|ext| ext.eq_ignore_ascii_case("ini"))
1216    {
1217        let text = String::from_utf8(raw).ok()?;
1218        return Some(GameexeConfig::from_text(&text));
1219    }
1220    let opt = load_gameexe_decode_options(project_dir).ok()?;
1221    let (text, _report) = decode_gameexe_dat_bytes(&raw, &opt).ok()?;
1222    Some(GameexeConfig::from_text(&text))
1223}
1224
1225#[cfg(all(test, not(target_arch = "wasm32")))]
1226mod streaming_tests {
1227    use super::*;
1228
1229    fn wav_source() -> Arc<[u8]> {
1230        let data_len = 48000u32 * 2;
1231        let mut wav = Vec::new();
1232        wav.extend_from_slice(b"RIFF");
1233        wav.extend_from_slice(&(36 + data_len).to_le_bytes());
1234        wav.extend_from_slice(b"WAVEfmt ");
1235        wav.extend_from_slice(&16u32.to_le_bytes());
1236        wav.extend_from_slice(&1u16.to_le_bytes());
1237        wav.extend_from_slice(&1u16.to_le_bytes());
1238        wav.extend_from_slice(&48000u32.to_le_bytes());
1239        wav.extend_from_slice(&96000u32.to_le_bytes());
1240        wav.extend_from_slice(&2u16.to_le_bytes());
1241        wav.extend_from_slice(&16u16.to_le_bytes());
1242        wav.extend_from_slice(b"data");
1243        wav.extend_from_slice(&data_len.to_le_bytes());
1244        wav.resize(44 + data_len as usize, 0);
1245        wav.into()
1246    }
1247
1248    #[test]
1249    fn native_bgm_keeps_shared_encoded_source_and_sample_ranges() {
1250        let source = wav_source();
1251        let slot = BgmPlayerSlot {
1252            source_bytes: Some(Arc::clone(&source)),
1253            end_sample: 40000,
1254            restart_sample: 12000,
1255            loop_flag: true,
1256            ..Default::default()
1257        };
1258        let data = slot.playback_data(6000, 0.5, 250).unwrap();
1259        assert_eq!(Arc::strong_count(&source), 3);
1260        assert_eq!(
1261            data.settings.start_position,
1262            PlaybackPosition::Samples(6000)
1263        );
1264        assert_eq!(data.slice, Some((0, 40000)));
1265        assert_eq!(data.num_frames(), 40000);
1266        assert_eq!(
1267            data.settings.loop_region,
1268            Some(Region {
1269                start: PlaybackPosition::Samples(12000),
1270                end: EndPosition::Custom(PlaybackPosition::Samples(40000)),
1271            })
1272        );
1273        assert_eq!(
1274            data.settings.fade_in_tween.unwrap().duration,
1275            Duration::from_millis(250)
1276        );
1277        drop(data);
1278        assert_eq!(Arc::strong_count(&source), 2);
1279    }
1280
1281    #[test]
1282    fn oneshot_stops_at_script_end_without_loop_or_implicit_fade() {
1283        let slot = BgmPlayerSlot {
1284            source_bytes: Some(wav_source()),
1285            end_sample: 24000,
1286            ..Default::default()
1287        };
1288        let data = slot.playback_data(12000, 1.0, 0).unwrap();
1289        assert_eq!(data.slice, Some((0, 24000)));
1290        assert!(data.settings.loop_region.is_none());
1291        assert!(data.settings.fade_in_tween.is_none());
1292    }
1293
1294    #[test]
1295    fn streaming_bgm_preserves_ready_pause_resume_and_retire_lifecycle() {
1296        let mut audio = AudioHub::new();
1297        let mut bgm = BgmEngine::new(std::env::temp_dir());
1298        bgm.players[0] = BgmPlayerSlot {
1299            source_bytes: Some(wav_source()),
1300            sample_rate_hz: 48000,
1301            total_samples: 48000,
1302            end_sample: 48000,
1303            loop_flag: true,
1304            ..Default::default()
1305        };
1306        bgm.current_player_id = Some(0);
1307        bgm.current_name = Some("test".to_owned());
1308        bgm.loop_flag = true;
1309
1310        bgm.ready_slot(&mut audio, 0).unwrap();
1311        assert_eq!(bgm.check_state(), TNM_PLAYER_STATE_PAUSE);
1312        assert_eq!(bgm.play_pos_samples(), 0);
1313        bgm.resume_script(&mut audio, 20, 0).unwrap();
1314        assert_eq!(bgm.check_state(), TNM_PLAYER_STATE_PLAY);
1315        bgm.pause_fade(&mut audio, 20).unwrap();
1316        assert_eq!(bgm.check_state(), TNM_PLAYER_STATE_FADE_OUT);
1317        bgm.players[0].pending.as_mut().unwrap().at = Instant::now();
1318        bgm.tick(&mut audio).unwrap();
1319        assert_eq!(bgm.check_state(), TNM_PLAYER_STATE_PAUSE);
1320        bgm.resume_script(&mut audio, 0, 0).unwrap();
1321        bgm.handoff_current_to_retired(20);
1322        assert_eq!(bgm.retired.len(), 1);
1323        assert_eq!(bgm.check_state(), TNM_PLAYER_STATE_FREE);
1324        bgm.retired[0].1 = Instant::now();
1325        bgm.tick(&mut audio).unwrap();
1326        assert!(bgm.retired.is_empty());
1327        bgm.start_slot(&mut audio, 0, 0).unwrap();
1328        bgm.stop().unwrap();
1329        assert_eq!(bgm.check_state(), TNM_PLAYER_STATE_FREE);
1330        assert!(bgm.current_name().is_none());
1331    }
1332
1333    fn check_context_releases_streaming_decoder(restart: bool) {
1334        let source = wav_source();
1335        let mut ctx = crate::runtime::CommandContext::new(std::env::temp_dir());
1336        ctx.bgm.players[0] = BgmPlayerSlot {
1337            source_bytes: Some(Arc::clone(&source)),
1338            sample_rate_hz: 48000,
1339            total_samples: 48000,
1340            end_sample: 48000,
1341            loop_flag: true,
1342            ..Default::default()
1343        };
1344        ctx.bgm.current_player_id = Some(0);
1345        ctx.bgm.start_slot(&mut ctx.audio, 0, 0).unwrap();
1346        std::thread::sleep(Duration::from_millis(20));
1347        if restart {
1348            ctx.reset_for_scene_restart();
1349        } else {
1350            drop(ctx);
1351        }
1352        let deadline = Instant::now() + Duration::from_millis(500);
1353        while Arc::strong_count(&source) > 1 && Instant::now() < deadline {
1354            std::thread::sleep(Duration::from_millis(5));
1355        }
1356        assert_eq!(
1357            Arc::strong_count(&source),
1358            1,
1359            "old BGM decoder leaked after shutdown"
1360        );
1361    }
1362
1363    #[test]
1364    fn restarting_context_releases_old_streaming_decoder() {
1365        check_context_releases_streaming_decoder(true);
1366    }
1367
1368    #[test]
1369    fn dropping_context_releases_old_streaming_decoder() {
1370        check_context_releases_streaming_decoder(false);
1371    }
1372}