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 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 #[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 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 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 ®ist_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}