1use crate::platform_time::{Duration, Instant};
2use std::collections::HashMap;
3use std::fs;
4use std::io::Cursor;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow, bail};
8
9use kira::Volume;
10use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
11use kira::tween::Tween;
12
13#[cfg(not(target_arch = "wasm32"))]
14type StreamingSoundData = kira::sound::streaming::StreamingSoundData<kira::sound::FromFileError>;
15#[cfg(not(target_arch = "wasm32"))]
16type StreamingSoundHandle =
17 kira::sound::streaming::StreamingSoundHandle<kira::sound::FromFileError>;
18
19#[cfg(not(target_arch = "wasm32"))]
24const STREAM_OGG_MIN_BYTES: u64 = 256 * 1024;
25
26#[derive(Debug)]
29enum SlotHandle {
30 Static(StaticSoundHandle),
31 #[cfg(not(target_arch = "wasm32"))]
32 Streaming(StreamingSoundHandle),
33}
34
35impl SlotHandle {
36 fn set_volume(&mut self, volume: Volume, tween: Tween) {
37 match self {
38 Self::Static(handle) => handle.set_volume(volume, tween),
39 #[cfg(not(target_arch = "wasm32"))]
40 Self::Streaming(handle) => handle.set_volume(volume, tween),
41 }
42 }
43
44 fn pause(&mut self, tween: Tween) {
45 match self {
46 Self::Static(handle) => handle.pause(tween),
47 #[cfg(not(target_arch = "wasm32"))]
48 Self::Streaming(handle) => handle.pause(tween),
49 }
50 }
51
52 fn resume(&mut self, tween: Tween) {
53 match self {
54 Self::Static(handle) => handle.resume(tween),
55 #[cfg(not(target_arch = "wasm32"))]
56 Self::Streaming(handle) => handle.resume(tween),
57 }
58 }
59
60 fn stop(&mut self, tween: Tween) {
61 match self {
62 Self::Static(handle) => handle.stop(tween),
63 #[cfg(not(target_arch = "wasm32"))]
64 Self::Streaming(handle) => handle.stop(tween),
65 }
66 }
67}
68
69enum SlotSound {
71 Static(StaticSoundData),
72 #[cfg(not(target_arch = "wasm32"))]
73 Streaming(StreamingSoundData),
74}
75
76use crate::audio::bgm::{
77 KoeSource, decode_bgm_to_wav_bytes, decode_ovk_entry_by_no_to_wav_bytes, resolve_koe_source,
78};
79use crate::audio::{AudioHub, TrackKind};
80
81fn decode_koe_no_for_project(project_dir: &Path, koe_no: i64) -> Result<Vec<u8>> {
82 let resolved = resolve_koe_source(project_dir, koe_no)?;
83 let wav = match &resolved {
84 KoeSource::File(path) => {
85 decode_bgm_to_wav_bytes(path, None)
86 .with_context(|| format!("decode KOE file: {}", path.display()))?
87 .wav_bytes
88 }
89 KoeSource::OvkEntryByNo { path, entry_no } => {
90 decode_ovk_entry_by_no_to_wav_bytes(path, *entry_no)
91 .with_context(|| format!("decode KOE OVK entry: {}#{entry_no}", path.display()))?
92 .wav_bytes
93 }
94 };
95 if env_is_set!("SG_AUDIO_TRACE") {
96 eprintln!(
97 "[SG_AUDIO_TRACE] koe resolved koe_no={} source={:?} wav_ms={:?}",
98 koe_no,
99 resolved,
100 wav_duration_ms(&wav)
101 );
102 }
103 Ok(wav)
104}
105
106pub(crate) fn wav_duration_ms(wav: &[u8]) -> Option<u64> {
111 if wav.len() < 44 {
113 return None;
114 }
115 if &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" {
116 return None;
117 }
118
119 let mut pos = 12usize;
120 let mut byte_rate: Option<u32> = None;
121 let mut data_size: Option<u32> = None;
122
123 while pos + 8 <= wav.len() {
124 let id = &wav[pos..pos + 4];
125 let sz =
126 u32::from_le_bytes([wav[pos + 4], wav[pos + 5], wav[pos + 6], wav[pos + 7]]) as usize;
127 pos += 8;
128 if pos + sz > wav.len() {
129 break;
130 }
131 if id == b"fmt " {
132 if sz >= 16 {
133 let off = pos + 8;
135 if off + 4 <= wav.len() {
136 byte_rate = Some(u32::from_le_bytes([
137 wav[off],
138 wav[off + 1],
139 wav[off + 2],
140 wav[off + 3],
141 ]));
142 }
143 }
144 } else if id == b"data" {
145 data_size = Some(sz as u32);
146 }
147
148 pos += sz;
150 if (sz & 1) != 0 {
151 pos += 1;
152 }
153
154 if byte_rate.is_some() && data_size.is_some() {
155 break;
156 }
157 }
158
159 let br = byte_rate?;
160 if br == 0 {
161 return None;
162 }
163 let ds = data_size? as u64;
164 Some((ds * 1000) / (br as u64))
165}
166
167#[derive(Debug)]
168struct Slot {
169 handle: Option<SlotHandle>,
170 until: Option<Instant>,
173 duration_ms: Option<u64>,
174 looping: bool,
175 last_name: Option<String>,
176 paused_at: Option<Instant>,
179 ready_only: bool,
181 fade_until: Option<Instant>,
184 resume_at: Option<Instant>,
186 resume_fade_ms: i64,
187 volume_raw: u8,
189 system_volume_raw: u8,
192}
193
194impl Default for Slot {
195 fn default() -> Self {
196 Self {
197 handle: None,
198 until: None,
199 duration_ms: None,
200 looping: false,
201 last_name: None,
202 paused_at: None,
203 ready_only: false,
204 fade_until: None,
205 resume_at: None,
206 resume_fade_ms: 0,
207 volume_raw: 255,
208 system_volume_raw: 255,
209 }
210 }
211}
212
213impl Slot {
214 fn amplitude(&self) -> f64 {
215 let combined = u16::from(self.volume_raw) * u16::from(self.system_volume_raw) / 255;
216 f64::from(combined) / 255.0
217 }
218
219 fn tween_for_ms(ms: i64) -> Tween {
220 if ms > 0 {
221 Tween {
222 duration: Duration::from_millis(ms as u64),
223 ..Tween::default()
224 }
225 } else {
226 Tween::default()
227 }
228 }
229
230 fn clear(&mut self) {
231 self.handle = None;
232 self.until = None;
233 self.duration_ms = None;
234 self.looping = false;
235 self.last_name = None;
236 self.paused_at = None;
237 self.ready_only = false;
238 self.fade_until = None;
239 self.resume_at = None;
240 self.resume_fade_ms = 0;
241 }
242
243 fn has_source(&self) -> bool {
244 self.last_name.is_some()
245 }
246
247 fn resume_now(&mut self, fade_ms: i64) {
248 if !self.has_source() || self.fade_until.is_some() {
249 self.resume_at = None;
250 self.resume_fade_ms = 0;
251 return;
252 }
253
254 let now = Instant::now();
255 let was_ready = self.ready_only;
256 if let Some(mut handle) = self.handle.take() {
257 if fade_ms > 0 {
258 let amplitude = self.amplitude();
259 handle.set_volume(Volume::Amplitude(0.0), Tween::default());
260 handle.resume(Tween::default());
261 handle.set_volume(Volume::Amplitude(amplitude), Self::tween_for_ms(fade_ms));
262 } else {
263 let amplitude = self.amplitude();
264 handle.resume(Tween::default());
265 handle.set_volume(Volume::Amplitude(amplitude), Tween::default());
266 }
267 self.handle = Some(handle);
268 }
269
270 if was_ready {
271 if self.looping {
272 self.until = None;
273 } else {
274 let duration_ms = self.duration_ms.unwrap_or(2000);
275 self.until = Some(now + Duration::from_millis(duration_ms));
276 }
277 } else if let Some(paused_at) = self.paused_at {
278 let effective = if paused_at > now { now } else { paused_at };
281 let paused_for = now.saturating_duration_since(effective);
282 if let Some(until) = self.until {
283 self.until = Some(until + paused_for);
284 }
285 }
286
287 self.paused_at = None;
288 self.ready_only = false;
289 self.fade_until = None;
290 self.resume_at = None;
291 self.resume_fade_ms = 0;
292 }
293
294 fn tick(&mut self) {
295 let now = Instant::now();
296 if self.fade_until.is_some_and(|at| now >= at) {
297 self.clear();
298 return;
299 }
300 if self.resume_at.is_some_and(|at| now >= at) {
301 self.resume_now(self.resume_fade_ms);
302 }
303
304 let fully_paused = self.paused_at.is_some_and(|at| now >= at);
305 if !self.looping
306 && !self.ready_only
307 && !fully_paused
308 && self.until.is_some_and(|at| now >= at)
309 {
310 self.clear();
311 }
312 }
313
314 fn is_playing(&mut self) -> bool {
315 self.tick();
316 if !self.has_source()
317 || self.fade_until.is_some()
318 || self.ready_only
319 || self.paused_at.is_some()
320 || self.resume_at.is_some()
321 {
322 return false;
323 }
324 self.looping || self.until.is_some()
325 }
326
327 fn is_fading_out(&mut self) -> bool {
328 self.tick();
329 self.fade_until.is_some()
330 }
331
332 fn play_pos_ms(&mut self) -> u64 {
333 self.tick();
334 let Some(duration) = self.duration_ms else {
335 return 0;
336 };
337 if self.ready_only || !self.has_source() {
338 return 0;
339 }
340 let now = self.paused_at.unwrap_or_else(Instant::now);
341 if let Some(until) = self.until {
342 let remaining = until.saturating_duration_since(now).as_millis() as u64;
343 return duration.saturating_sub(remaining.min(duration));
344 }
345 0
346 }
347
348 fn needs_tick(&self) -> bool {
349 self.resume_at.is_some() || self.fade_until.is_some()
350 }
351}
352
353pub struct SfxEngine {
354 project_dir: PathBuf,
355 sub_dir: String,
356 volume_raw: u8,
357 track_kind: TrackKind,
358 slots: Vec<Slot>,
359 #[cfg(not(target_arch = "wasm32"))]
361 streamed_durations: HashMap<PathBuf, Option<u64>>,
362}
363
364impl SfxEngine {
365 pub fn new(
366 project_dir: PathBuf,
367 sub_dir: impl Into<String>,
368 track_kind: TrackKind,
369 slot_cnt: usize,
370 ) -> Self {
371 Self {
372 project_dir,
373 sub_dir: sub_dir.into(),
374 volume_raw: 255,
375 track_kind,
376 slots: (0..slot_cnt).map(|_| Slot::default()).collect(),
377 #[cfg(not(target_arch = "wasm32"))]
378 streamed_durations: HashMap::new(),
379 }
380 }
381
382 pub fn slot_cnt(&self) -> usize {
383 self.slots.len()
384 }
385
386 pub fn volume_raw(&self) -> u8 {
387 self.volume_raw
388 }
389
390 pub fn set_volume_raw(&mut self, audio: &mut AudioHub, volume_raw: u8) -> Result<()> {
391 self.volume_raw = volume_raw;
392 audio.set_track_volume_raw(self.track_kind, volume_raw);
393 Ok(())
394 }
395
396 pub fn set_volume_raw_fade(
397 &mut self,
398 audio: &mut AudioHub,
399 volume_raw: u8,
400 fade_ms: i64,
401 ) -> Result<()> {
402 self.volume_raw = volume_raw;
403 audio.set_track_volume_raw_fade(self.track_kind, volume_raw, fade_ms);
404 Ok(())
405 }
406
407 pub fn is_playing_any(&mut self) -> bool {
408 self.slots.iter_mut().any(|s| s.is_playing())
409 }
410
411 pub fn is_playing_slot(&mut self, slot: usize) -> bool {
412 self.slots
413 .get_mut(slot)
414 .map(|s| s.is_playing())
415 .unwrap_or(false)
416 }
417
418 pub fn slot_play_pos_ms(&mut self, slot: usize) -> u64 {
419 self.slots
420 .get_mut(slot)
421 .map(|s| s.play_pos_ms())
422 .unwrap_or(0)
423 }
424
425 pub fn is_fading_slot(&mut self, slot: usize) -> bool {
426 self.slots
427 .get_mut(slot)
428 .map(|s| s.is_fading_out())
429 .unwrap_or(false)
430 }
431
432 pub fn last_name_slot(&self, slot: usize) -> Option<&str> {
433 self.slots.get(slot).and_then(|s| s.last_name.as_deref())
434 }
435
436 pub fn stop_all(&mut self, fade_time_ms: Option<i64>) -> Result<()> {
437 for slot in 0..self.slots.len() {
438 self.stop_slot(slot, fade_time_ms)?;
439 }
440 Ok(())
441 }
442
443 pub fn stop_slot(&mut self, slot: usize, fade_time_ms: Option<i64>) -> Result<()> {
444 let Some(s) = self.slots.get_mut(slot) else {
445 return Ok(());
446 };
447 s.tick();
448 let fade_ms = fade_time_ms.unwrap_or(0).max(0);
449 if fade_ms == 0 || !s.has_source() {
450 if let Some(mut handle) = s.handle.take() {
451 handle.stop(Tween::default());
452 }
453 s.clear();
454 return Ok(());
455 }
456
457 if let Some(handle) = &mut s.handle {
458 handle.stop(Slot::tween_for_ms(fade_ms));
459 }
460 s.until = None;
461 s.paused_at = None;
462 s.ready_only = false;
463 s.resume_at = None;
464 s.resume_fade_ms = 0;
465 s.fade_until = Some(Instant::now() + Duration::from_millis(fade_ms as u64));
466 Ok(())
467 }
468
469 pub fn play_file_name_in_slot(
470 &mut self,
471 audio: &mut AudioHub,
472 slot: usize,
473 file_name: &str,
474 loop_flag: bool,
475 ) -> Result<PathBuf> {
476 self.play_file_name_in_slot_with_options(audio, slot, file_name, loop_flag, 0, false)
477 }
478
479 pub fn play_file_name_in_slot_with_options(
480 &mut self,
481 audio: &mut AudioHub,
482 slot: usize,
483 file_name: &str,
484 loop_flag: bool,
485 fade_in_ms: i64,
486 ready_only: bool,
487 ) -> Result<PathBuf> {
488 if slot >= self.slots.len() {
489 bail!("slot out of range: {slot}");
490 }
491 let path = self.resolve_path(file_name)?;
492 #[cfg(not(target_arch = "wasm32"))]
493 if let Some(bytes) = self.streamable_ogg(&path)? {
494 let duration_ms = self.streamed_ogg_duration_ms(&path, &bytes)?;
495 let data = StreamingSoundData::from_cursor(Cursor::new(bytes))
496 .with_context(|| format!("kira: open OGG stream: {}", path.display()))?;
497 self.start_in_slot(
498 audio,
499 slot,
500 file_name,
501 Some(SlotSound::Streaming(data)),
502 duration_ms,
503 loop_flag,
504 fade_in_ms,
505 ready_only,
506 )?;
507 return Ok(path);
508 }
509 let wav = self.decode_to_wav(&path)?;
510 self.play_decoded_wav_in_slot_with_options(
511 audio, slot, file_name, wav, loop_flag, fade_in_ms, ready_only,
512 )?;
513 Ok(path)
514 }
515
516 pub fn play_koe_no_in_slot(
517 &mut self,
518 audio: &mut AudioHub,
519 slot: usize,
520 koe_no: i64,
521 loop_flag: bool,
522 ) -> Result<()> {
523 self.play_koe_no_in_slot_with_options(audio, slot, koe_no, loop_flag, 0, false)
524 }
525
526 pub fn play_koe_no_in_slot_with_options(
527 &mut self,
528 audio: &mut AudioHub,
529 slot: usize,
530 koe_no: i64,
531 loop_flag: bool,
532 fade_in_ms: i64,
533 ready_only: bool,
534 ) -> Result<()> {
535 if slot >= self.slots.len() {
536 bail!("slot out of range: {slot}");
537 }
538
539 let wav = self.decode_koe_no(koe_no)?;
540 self.play_decoded_wav_in_slot_with_options(
541 audio,
542 slot,
543 &format!("koe:{koe_no}"),
544 wav,
545 loop_flag,
546 fade_in_ms,
547 ready_only,
548 )
549 }
550
551 fn decode_koe_no(&self, koe_no: i64) -> Result<Vec<u8>> {
552 decode_koe_no_for_project(&self.project_dir, koe_no)
553 }
554
555 fn play_decoded_wav_in_slot(
556 &mut self,
557 audio: &mut AudioHub,
558 slot: usize,
559 display_name: &str,
560 wav: Vec<u8>,
561 loop_flag: bool,
562 ) -> Result<()> {
563 self.play_decoded_wav_in_slot_with_options(
564 audio,
565 slot,
566 display_name,
567 wav,
568 loop_flag,
569 0,
570 false,
571 )
572 }
573
574 fn play_decoded_wav_in_slot_with_options(
575 &mut self,
576 audio: &mut AudioHub,
577 slot: usize,
578 display_name: &str,
579 wav: Vec<u8>,
580 loop_flag: bool,
581 fade_in_ms: i64,
582 ready_only: bool,
583 ) -> Result<()> {
584 if slot >= self.slots.len() {
585 bail!("slot out of range: {slot}");
586 }
587 let duration_ms = wav_duration_ms(&wav).or(Some(2000));
588 let sound = if audio.is_enabled() {
589 Some(SlotSound::Static(
590 StaticSoundData::from_cursor(Cursor::new(wav)).context("kira: decode WAV bytes")?,
591 ))
592 } else {
593 None
594 };
595 self.start_in_slot(
596 audio,
597 slot,
598 display_name,
599 sound,
600 duration_ms,
601 loop_flag,
602 fade_in_ms,
603 ready_only,
604 )
605 }
606
607 #[cfg(not(target_arch = "wasm32"))]
611 fn streamed_ogg_duration_ms(&mut self, path: &Path, bytes: &[u8]) -> Result<Option<u64>> {
612 if let Some(&duration) = self.streamed_durations.get(path) {
613 return Ok(duration);
614 }
615 let (channels, sample_rate, samples) =
616 siglus_assets::vorbis::ogg_vorbis_decoded_len(Cursor::new(bytes))
617 .with_context(|| format!("measure ogg: {}", path.display()))?;
618 let data_size = u64::from((samples * 2) as u32);
621 let byte_rate = sample_rate.saturating_mul(u32::from(channels.saturating_mul(2)));
622 let duration = (byte_rate != 0)
623 .then(|| data_size * 1000 / u64::from(byte_rate))
624 .or(Some(2000));
625 self.streamed_durations.insert(path.to_path_buf(), duration);
626 Ok(duration)
627 }
628
629 #[cfg(not(target_arch = "wasm32"))]
633 fn streamable_ogg(&self, path: &Path) -> Result<Option<Vec<u8>>> {
634 let is_ogg = path
635 .extension()
636 .and_then(|ext| ext.to_str())
637 .is_some_and(|ext| ext.eq_ignore_ascii_case("ogg"));
638 if !is_ogg {
639 return Ok(None);
640 }
641 let bytes = crate::resource::read_file_bytes(path)
642 .with_context(|| format!("read ogg: {}", path.display()))?;
643 Ok((bytes.len() as u64 >= STREAM_OGG_MIN_BYTES).then_some(bytes))
644 }
645
646 #[allow(clippy::too_many_arguments)]
647 fn start_in_slot(
648 &mut self,
649 audio: &mut AudioHub,
650 slot: usize,
651 display_name: &str,
652 sound: Option<SlotSound>,
653 duration_ms: Option<u64>,
654 loop_flag: bool,
655 fade_in_ms: i64,
656 ready_only: bool,
657 ) -> Result<()> {
658 self.stop_slot(slot, None)?;
660
661 let mut handle = None;
662 if let Some(sound) = sound.filter(|_| audio.is_enabled()) {
663 let mut new_handle = match sound {
664 SlotSound::Static(mut data) => {
665 if loop_flag {
666 data = data.loop_region(0.0..);
667 }
668 SlotHandle::Static(audio.play_static(self.track_kind, data)?)
669 }
670 #[cfg(not(target_arch = "wasm32"))]
671 SlotSound::Streaming(mut data) => {
672 if loop_flag {
673 data = data.loop_region(0.0..);
674 }
675 SlotHandle::Streaming(audio.play_streaming(self.track_kind, data)?)
676 }
677 };
678 let amplitude = self.slots[slot].amplitude();
679 if ready_only {
680 new_handle.set_volume(Volume::Amplitude(amplitude), Tween::default());
681 new_handle.pause(Tween::default());
682 } else if fade_in_ms > 0 {
683 new_handle.set_volume(Volume::Amplitude(0.0), Tween::default());
684 new_handle.set_volume(Volume::Amplitude(amplitude), Slot::tween_for_ms(fade_in_ms));
685 } else {
686 new_handle.set_volume(Volume::Amplitude(amplitude), Tween::default());
690 }
691 handle = Some(new_handle);
692 }
693
694 let now = Instant::now();
695 let s = &mut self.slots[slot];
696 s.handle = handle;
697 s.duration_ms = duration_ms;
698 s.looping = loop_flag;
699 s.last_name = Some(display_name.to_string());
700 s.ready_only = ready_only;
701 s.fade_until = None;
702 s.paused_at = ready_only.then_some(now);
703 s.resume_at = None;
704 s.resume_fade_ms = 0;
705 s.until = if ready_only || loop_flag {
706 None
707 } else {
708 duration_ms.map(|ms| now + Duration::from_millis(ms))
709 };
710
711 Ok(())
712 }
713
714 pub fn slot_volume_raw(&self, slot: usize) -> u8 {
715 self.slots.get(slot).map(|s| s.volume_raw).unwrap_or(255)
716 }
717
718 pub fn slot_system_volume_raw(&self, slot: usize) -> u8 {
719 self.slots
720 .get(slot)
721 .map(|s| s.system_volume_raw)
722 .unwrap_or(255)
723 }
724
725 pub fn slot_duration_ms(&self, slot: usize) -> u64 {
726 self.slots
727 .get(slot)
728 .and_then(|s| s.duration_ms)
729 .unwrap_or(0)
730 }
731
732 pub fn slot_loop_flag(&self, slot: usize) -> bool {
733 self.slots.get(slot).map(|s| s.looping).unwrap_or(false)
734 }
735
736 pub fn slot_ready_flag(&self, slot: usize) -> bool {
737 self.slots.get(slot).map(|s| s.ready_only).unwrap_or(false)
738 }
739
740 pub fn slot_resume_delay_ms(&self, slot: usize) -> i64 {
741 self.slots
742 .get(slot)
743 .and_then(|s| s.resume_at)
744 .map(|deadline| {
745 deadline
746 .saturating_duration_since(Instant::now())
747 .as_millis()
748 .min(i64::MAX as u128) as i64
749 })
750 .unwrap_or(0)
751 }
752
753 pub fn set_slot_volume_raw_fade(
754 &mut self,
755 slot: usize,
756 volume_raw: u8,
757 fade_ms: i64,
758 ) -> Result<()> {
759 let Some(s) = self.slots.get_mut(slot) else {
760 return Ok(());
761 };
762 s.volume_raw = volume_raw;
763 let amplitude = s.amplitude();
764 if let Some(handle) = &mut s.handle {
765 handle.set_volume(
766 Volume::Amplitude(amplitude),
767 Slot::tween_for_ms(fade_ms.max(0)),
768 );
769 }
770 Ok(())
771 }
772
773 pub fn set_slot_system_volume_raw(&mut self, slot: usize, volume_raw: u8) -> Result<()> {
774 let Some(s) = self.slots.get_mut(slot) else {
775 return Ok(());
776 };
777 if s.system_volume_raw == volume_raw {
778 return Ok(());
779 }
780 s.system_volume_raw = volume_raw;
781 let amplitude = s.amplitude();
782 if let Some(handle) = &mut s.handle {
783 handle.set_volume(Volume::Amplitude(amplitude), Tween::default());
784 }
785 Ok(())
786 }
787
788 pub fn pause_slot(&mut self, slot: usize, fade_time_ms: Option<i64>) -> Result<()> {
789 let Some(s) = self.slots.get_mut(slot) else {
790 return Ok(());
791 };
792 s.tick();
793 if !s.has_source() || s.ready_only || s.paused_at.is_some() || s.resume_at.is_some() {
794 return Ok(());
795 }
796 let fade_ms = fade_time_ms.unwrap_or(0).max(0);
797 if let Some(handle) = &mut s.handle {
798 handle.pause(Slot::tween_for_ms(fade_ms));
799 }
800 let now = Instant::now();
801 s.paused_at = Some(now + Duration::from_millis(fade_ms as u64));
802 s.resume_at = None;
803 s.resume_fade_ms = 0;
804 Ok(())
805 }
806
807 pub fn resume_slot(&mut self, slot: usize, fade_ms: i64, delay_ms: i64) -> Result<()> {
808 let Some(s) = self.slots.get_mut(slot) else {
809 return Ok(());
810 };
811 s.tick();
812 if !s.has_source() || s.fade_until.is_some() || (!s.ready_only && s.paused_at.is_none()) {
813 return Ok(());
814 }
815 let delay_ms = delay_ms.max(0);
816 if delay_ms == 0 {
817 s.resume_now(fade_ms.max(0));
818 } else {
819 s.resume_at = Some(Instant::now() + Duration::from_millis(delay_ms as u64));
820 s.resume_fade_ms = fade_ms.max(0);
821 }
822 Ok(())
823 }
824
825 pub fn tick(&mut self) {
826 for slot in &mut self.slots {
827 slot.tick();
828 }
829 }
830
831 pub fn needs_tick(&self) -> bool {
832 self.slots.iter().any(Slot::needs_tick)
833 }
834
835 fn resolve_path(&self, file_name: &str) -> Result<PathBuf> {
836 let direct = Path::new(file_name);
837 if path_exists(direct) {
838 return Ok(direct.to_path_buf());
839 }
840
841 if let Ok((path, _ty)) = crate::resource::find_audio_path_with_append_dir(
842 &self.project_dir,
843 "",
844 &self.sub_dir,
845 file_name,
846 ) {
847 return Ok(path);
848 }
849
850 let dir = self.project_dir.join(&self.sub_dir);
851 let base = dir.join(file_name);
852
853 if base.extension().is_some() && path_exists(&base) {
854 return Ok(base);
855 }
856
857 let candidates = ["wav", "nwa", "ogg", "owp", "ovk"];
858 for ext in candidates {
859 let p = base.with_extension(ext);
860 if path_exists(&p) {
861 return Ok(p);
862 }
863 }
864
865 bail!(
866 "sound file not found: name={:?} (project_dir={:?}, sub_dir={:?})",
867 file_name,
868 self.project_dir,
869 self.sub_dir
870 );
871 }
872
873 fn decode_to_wav(&self, path: &Path) -> Result<Vec<u8>> {
874 let ext = path
875 .extension()
876 .and_then(|s| s.to_str())
877 .unwrap_or("")
878 .to_ascii_lowercase();
879
880 match ext.as_str() {
881 "wav" => crate::resource::read_file_bytes(path)
882 .with_context(|| format!("read wav: {}", path.display())),
883 "nwa" | "ogg" | "owp" | "ovk" => {
884 let decoded = decode_bgm_to_wav_bytes(path, None)
885 .with_context(|| format!("decode audio: {}", path.display()))?;
886 Ok(decoded.wav_bytes)
887 }
888 _ => Err(anyhow!("unsupported sound extension: {}", path.display())),
889 }
890 }
891}
892
893fn path_exists(path: &Path) -> bool {
894 crate::resource::game_file_exists(path)
895}
896
897pub struct PcmEngine {
898 inner: SfxEngine,
899}
900
901impl PcmEngine {
902 pub fn new(project_dir: PathBuf) -> Self {
903 Self {
906 inner: SfxEngine::new(project_dir, "wav", TrackKind::Pcm, 17),
907 }
908 }
909
910 fn channel_slot(channel: usize) -> usize {
911 channel.saturating_add(1)
912 }
913
914 pub fn play_file_name(&mut self, audio: &mut AudioHub, file_name: &str) -> Result<PathBuf> {
915 self.inner
916 .play_file_name_in_slot(audio, 0, file_name, false)
917 }
918
919 pub fn play_koe_no(&mut self, audio: &mut AudioHub, koe_no: i64) -> Result<()> {
920 self.inner.play_koe_no_in_slot(audio, 0, koe_no, false)
921 }
922
923 pub fn play_in_slot(
924 &mut self,
925 audio: &mut AudioHub,
926 slot: usize,
927 file_name: &str,
928 loop_flag: bool,
929 ) -> Result<PathBuf> {
930 self.inner
931 .play_file_name_in_slot(audio, Self::channel_slot(slot), file_name, loop_flag)
932 }
933
934 pub fn play_in_slot_with_options(
935 &mut self,
936 audio: &mut AudioHub,
937 slot: usize,
938 file_name: &str,
939 loop_flag: bool,
940 fade_in_ms: i64,
941 ready_only: bool,
942 ) -> Result<PathBuf> {
943 self.inner.play_file_name_in_slot_with_options(
944 audio,
945 Self::channel_slot(slot),
946 file_name,
947 loop_flag,
948 fade_in_ms,
949 ready_only,
950 )
951 }
952
953 pub fn play_koe_no_in_slot(
954 &mut self,
955 audio: &mut AudioHub,
956 slot: usize,
957 koe_no: i64,
958 loop_flag: bool,
959 ) -> Result<()> {
960 self.inner
961 .play_koe_no_in_slot(audio, Self::channel_slot(slot), koe_no, loop_flag)
962 }
963
964 pub fn play_koe_no_in_slot_with_options(
965 &mut self,
966 audio: &mut AudioHub,
967 slot: usize,
968 koe_no: i64,
969 loop_flag: bool,
970 fade_in_ms: i64,
971 ready_only: bool,
972 ) -> Result<()> {
973 self.inner.play_koe_no_in_slot_with_options(
974 audio,
975 Self::channel_slot(slot),
976 koe_no,
977 loop_flag,
978 fade_in_ms,
979 ready_only,
980 )
981 }
982
983 pub fn play_decoded_wav_in_slot(
984 &mut self,
985 audio: &mut AudioHub,
986 slot: usize,
987 display_name: &str,
988 wav: Vec<u8>,
989 loop_flag: bool,
990 ) -> Result<()> {
991 self.inner.play_decoded_wav_in_slot(
992 audio,
993 Self::channel_slot(slot),
994 display_name,
995 wav,
996 loop_flag,
997 )
998 }
999
1000 pub fn play_decoded_wav_in_slot_with_options(
1001 &mut self,
1002 audio: &mut AudioHub,
1003 slot: usize,
1004 display_name: &str,
1005 wav: Vec<u8>,
1006 loop_flag: bool,
1007 fade_in_ms: i64,
1008 ready_only: bool,
1009 ) -> Result<()> {
1010 self.inner.play_decoded_wav_in_slot_with_options(
1011 audio,
1012 Self::channel_slot(slot),
1013 display_name,
1014 wav,
1015 loop_flag,
1016 fade_in_ms,
1017 ready_only,
1018 )
1019 }
1020
1021 pub fn stop(&mut self, fade_time_ms: Option<i64>) -> Result<()> {
1022 self.inner.stop_slot(0, fade_time_ms)
1023 }
1024
1025 pub fn stop_slot(&mut self, slot: usize, fade_time_ms: Option<i64>) -> Result<()> {
1026 self.inner.stop_slot(Self::channel_slot(slot), fade_time_ms)
1027 }
1028
1029 pub fn stop_all(&mut self, fade_time_ms: Option<i64>) -> Result<()> {
1030 self.inner.stop_all(fade_time_ms)
1031 }
1032
1033 pub fn pause_slot(&mut self, slot: usize, fade_time_ms: Option<i64>) -> Result<()> {
1034 self.inner
1035 .pause_slot(Self::channel_slot(slot), fade_time_ms)
1036 }
1037
1038 pub fn resume_slot(&mut self, slot: usize, fade_ms: i64, delay_ms: i64) -> Result<()> {
1039 self.inner
1040 .resume_slot(Self::channel_slot(slot), fade_ms, delay_ms)
1041 }
1042
1043 pub fn tick(&mut self) {
1044 self.inner.tick();
1045 }
1046
1047 pub fn needs_tick(&self) -> bool {
1048 self.inner.needs_tick()
1049 }
1050
1051 pub fn is_playing_any(&mut self) -> bool {
1052 self.inner.is_playing_any()
1053 }
1054
1055 pub fn is_playing_slot(&mut self, slot: usize) -> bool {
1056 self.inner.is_playing_slot(Self::channel_slot(slot))
1057 }
1058
1059 pub fn is_fading_slot(&mut self, slot: usize) -> bool {
1060 self.inner.is_fading_slot(Self::channel_slot(slot))
1061 }
1062
1063 pub fn slot_volume_raw(&self, slot: usize) -> u8 {
1064 self.inner.slot_volume_raw(Self::channel_slot(slot))
1065 }
1066
1067 pub fn slot_loop_flag(&self, slot: usize) -> bool {
1068 self.inner.slot_loop_flag(Self::channel_slot(slot))
1069 }
1070
1071 pub fn slot_ready_flag(&self, slot: usize) -> bool {
1072 self.inner.slot_ready_flag(Self::channel_slot(slot))
1073 }
1074
1075 pub fn slot_resume_delay_ms(&self, slot: usize) -> i64 {
1076 self.inner.slot_resume_delay_ms(Self::channel_slot(slot))
1077 }
1078
1079 pub fn set_slot_volume_raw_fade(
1080 &mut self,
1081 slot: usize,
1082 volume_raw: u8,
1083 fade_ms: i64,
1084 ) -> Result<()> {
1085 self.inner
1086 .set_slot_volume_raw_fade(Self::channel_slot(slot), volume_raw, fade_ms)
1087 }
1088
1089 pub fn global_duration_ms(&self) -> u64 {
1090 self.inner.slot_duration_ms(0)
1091 }
1092
1093 pub fn slot_duration_ms(&self, slot: usize) -> u64 {
1094 self.inner.slot_duration_ms(Self::channel_slot(slot))
1095 }
1096
1097 pub fn set_global_system_volume_raw(&mut self, volume_raw: u8) -> Result<()> {
1098 self.inner.set_slot_system_volume_raw(0, volume_raw)
1099 }
1100
1101 pub fn set_slot_system_volume_raw(&mut self, slot: usize, volume_raw: u8) -> Result<()> {
1102 self.inner
1103 .set_slot_system_volume_raw(Self::channel_slot(slot), volume_raw)
1104 }
1105
1106 pub fn volume_raw(&self) -> u8 {
1107 self.inner.volume_raw()
1108 }
1109
1110 pub fn set_volume_raw(&mut self, audio: &mut AudioHub, volume_raw: u8) -> Result<()> {
1111 self.inner.set_volume_raw(audio, volume_raw)
1112 }
1113
1114 pub fn set_volume_raw_fade(
1115 &mut self,
1116 audio: &mut AudioHub,
1117 volume_raw: u8,
1118 fade_ms: i64,
1119 ) -> Result<()> {
1120 self.inner.set_volume_raw_fade(audio, volume_raw, fade_ms)
1121 }
1122}
1123
1124fn load_koe_mouth_volume_table(
1125 project_dir: &Path,
1126 current_append_dir: &str,
1127 koe_no: i64,
1128) -> Result<Vec<f32>> {
1129 let name = format!("z{koe_no:09}.vol.csv");
1130 let Some(path) =
1131 crate::resource::resolve_dat_file_path(project_dir, current_append_dir, &name)?
1132 else {
1133 return Ok(Vec::new());
1136 };
1137
1138 let bytes = crate::resource::read_file_bytes(&path)
1139 .with_context(|| format!("read KOE mouth-volume table: {}", path.display()))?;
1140 if bytes.len() % 2 != 0 {
1141 bail!(
1142 "KOE mouth-volume table has odd UTF-16 byte length: {}",
1143 path.display()
1144 );
1145 }
1146
1147 let (payload, big_endian) = if bytes.starts_with(&[0xff, 0xfe]) {
1148 (&bytes[2..], false)
1149 } else if bytes.starts_with(&[0xfe, 0xff]) {
1150 (&bytes[2..], true)
1151 } else {
1152 (&bytes[..], false)
1153 };
1154 let units: Vec<u16> = payload
1155 .as_chunks::<2>()
1156 .0
1157 .iter()
1158 .map(|pair| {
1159 if big_endian {
1160 u16::from_be_bytes([pair[0], pair[1]])
1161 } else {
1162 u16::from_le_bytes([pair[0], pair[1]])
1163 }
1164 })
1165 .collect();
1166 let text = String::from_utf16(&units).with_context(|| {
1167 format!(
1168 "decode KOE mouth-volume table as UTF-16: {}",
1169 path.display()
1170 )
1171 })?;
1172 let first_line = text.lines().next().unwrap_or("");
1173 if first_line.is_empty() {
1174 bail!("KOE mouth-volume table is empty: {}", path.display());
1175 }
1176
1177 first_line
1178 .split(',')
1179 .map(|part| {
1180 part.trim().parse::<f32>().with_context(|| {
1181 format!("parse KOE mouth-volume value {part:?}: {}", path.display())
1182 })
1183 })
1184 .collect()
1185}
1186
1187type PendingKoeDecode = (
1188 (i64, u16),
1189 String,
1190 std::sync::mpsc::Receiver<Result<Vec<u8>, String>>,
1191);
1192
1193pub struct KoeEngine {
1194 inner: SfxEngine,
1195 current_koe_no: i64,
1196 mouth_volume_table: Vec<f32>,
1200 pending_decode: Option<PendingKoeDecode>,
1203 decode_cache: HashMap<(i64, u16), std::sync::Arc<Vec<u8>>>,
1206}
1207
1208impl KoeEngine {
1209 pub fn debug_cache_memory(&self) -> (usize, usize) {
1211 let bytes = self.decode_cache.values().map(|wav| wav.len()).sum();
1212 (bytes, self.decode_cache.len())
1213 }
1214
1215 pub fn new(project_dir: PathBuf) -> Self {
1216 Self {
1219 inner: SfxEngine::new(project_dir, "wav", TrackKind::Koe, 1),
1220 current_koe_no: -1,
1221 mouth_volume_table: Vec::new(),
1222 pending_decode: None,
1223 decode_cache: HashMap::new(),
1224 }
1225 }
1226
1227 pub fn play_koe_no(
1228 &mut self,
1229 audio: &mut AudioHub,
1230 koe_no: i64,
1231 current_append_dir: &str,
1232 ) -> Result<()> {
1233 self.play_koe_no_with_rate(audio, koe_no, current_append_dir, 100)
1234 }
1235
1236 pub fn play_koe_no_with_rate(
1237 &mut self,
1238 audio: &mut AudioHub,
1239 koe_no: i64,
1240 current_append_dir: &str,
1241 jitan_rate: u16,
1242 ) -> Result<()> {
1243 let _ = self.stop(None);
1246 self.current_koe_no = -1;
1247 self.mouth_volume_table.clear();
1248
1249 if koe_no < 0 {
1250 return Ok(());
1251 }
1252
1253 let jitan_rate = jitan_rate.clamp(100, 400);
1257 let cache_key = (koe_no, jitan_rate);
1258 if let Some(wav) = self.decode_cache.get(&cache_key).cloned() {
1259 self.finish_koe_start(audio, koe_no, current_append_dir, (*wav).clone())?;
1260 return Ok(());
1261 }
1262
1263 let project_dir = self.inner.project_dir.clone();
1267 let append_dir = current_append_dir.to_string();
1268 let (tx, rx) = std::sync::mpsc::channel();
1269 std::thread::spawn(move || {
1270 let result = decode_koe_no_for_project(&project_dir, koe_no)
1271 .and_then(|wav| crate::audio::jitan::convert_koe_wav(wav, jitan_rate))
1272 .map_err(|err| format!("{err:#}"));
1273 let _ = tx.send(result);
1274 });
1275 self.pending_decode = Some((cache_key, append_dir, rx));
1276 Ok(())
1277 }
1278
1279 fn finish_koe_start(
1280 &mut self,
1281 audio: &mut AudioHub,
1282 koe_no: i64,
1283 current_append_dir: &str,
1284 wav: Vec<u8>,
1285 ) -> Result<()> {
1286 self.mouth_volume_table =
1287 load_koe_mouth_volume_table(&self.inner.project_dir, current_append_dir, koe_no)?;
1288 self.inner.play_decoded_wav_in_slot_with_options(
1289 audio,
1290 0,
1291 &format!("koe:{koe_no}"),
1292 wav,
1293 false,
1294 0,
1295 false,
1296 )?;
1297 self.current_koe_no = koe_no;
1298 Ok(())
1299 }
1300
1301 pub fn tick(&mut self, audio: &mut AudioHub) {
1302 let Some((cache_key, append_dir, rx)) = self.pending_decode.take() else {
1303 return;
1304 };
1305 let koe_no = cache_key.0;
1306 #[cfg(feature = "virtual-clock")]
1309 let received = rx
1310 .recv()
1311 .map_err(|_| std::sync::mpsc::TryRecvError::Disconnected);
1312 #[cfg(not(feature = "virtual-clock"))]
1313 let received = rx.try_recv();
1314 match received {
1315 Ok(Ok(wav)) => {
1316 if self.decode_cache.len() < 24 {
1317 self.decode_cache
1318 .insert(cache_key, std::sync::Arc::new(wav.clone()));
1319 }
1320 if let Err(err) = self.finish_koe_start(audio, koe_no, &append_dir, wav) {
1321 log::warn!("koe async start failed koe_no={koe_no}: {err:#}");
1322 }
1323 }
1324 Ok(Err(err)) => {
1325 log::warn!("koe async decode failed koe_no={koe_no}: {err}");
1326 }
1327 Err(std::sync::mpsc::TryRecvError::Empty) => {
1328 self.pending_decode = Some((cache_key, append_dir, rx));
1329 }
1330 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
1331 log::warn!("koe async decode worker died koe_no={koe_no}");
1332 }
1333 }
1334 }
1335
1336 pub fn stop(&mut self, fade_time_ms: Option<i64>) -> Result<()> {
1337 self.pending_decode = None;
1338 self.inner.stop_slot(0, fade_time_ms)
1339 }
1340
1341 pub fn is_playing_any(&mut self) -> bool {
1342 self.pending_decode.is_some() || self.inner.is_playing_any()
1343 }
1344
1345 pub fn current_koe_no(&self) -> i64 {
1346 self.current_koe_no
1347 }
1348
1349 pub fn current_play_pos_ms(&mut self) -> u64 {
1350 self.inner.slot_play_pos_ms(0)
1351 }
1352
1353 pub fn current_mouth_volume(&mut self) -> f32 {
1354 if !self.inner.is_playing_slot(0) {
1355 return 0.0;
1356 }
1357 let frame = (self.inner.slot_play_pos_ms(0).saturating_mul(60) / 1000) as usize;
1358 self.mouth_volume_table.get(frame).copied().unwrap_or(0.0)
1359 }
1360
1361 pub fn volume_raw(&self) -> u8 {
1362 self.inner.volume_raw()
1363 }
1364
1365 pub fn set_volume_raw(&mut self, audio: &mut AudioHub, volume_raw: u8) -> Result<()> {
1366 self.inner.set_volume_raw(audio, volume_raw)
1367 }
1368
1369 pub fn set_volume_raw_fade(
1370 &mut self,
1371 audio: &mut AudioHub,
1372 volume_raw: u8,
1373 fade_ms: i64,
1374 ) -> Result<()> {
1375 self.inner.set_volume_raw_fade(audio, volume_raw, fade_ms)
1376 }
1377}
1378
1379pub struct SeEngine {
1380 inner: SfxEngine,
1381}
1382
1383impl SeEngine {
1384 pub fn new(project_dir: PathBuf) -> Self {
1385 Self {
1387 inner: SfxEngine::new(project_dir, "wav", TrackKind::Se, 16),
1388 }
1389 }
1390
1391 pub fn play_file_name(&mut self, audio: &mut AudioHub, file_name: &str) -> Result<PathBuf> {
1392 self.inner
1393 .play_file_name_in_slot(audio, 0, file_name, false)
1394 }
1395
1396 pub fn play_koe_no(&mut self, audio: &mut AudioHub, koe_no: i64) -> Result<()> {
1397 self.inner.play_koe_no_in_slot(audio, 0, koe_no, false)
1398 }
1399
1400 pub fn play_in_slot(
1401 &mut self,
1402 audio: &mut AudioHub,
1403 slot: usize,
1404 file_name: &str,
1405 loop_flag: bool,
1406 ) -> Result<PathBuf> {
1407 self.inner
1408 .play_file_name_in_slot(audio, slot, file_name, loop_flag)
1409 }
1410
1411 pub fn play_koe_no_in_slot(
1412 &mut self,
1413 audio: &mut AudioHub,
1414 slot: usize,
1415 koe_no: i64,
1416 loop_flag: bool,
1417 ) -> Result<()> {
1418 self.inner
1419 .play_koe_no_in_slot(audio, slot, koe_no, loop_flag)
1420 }
1421
1422 pub fn play_decoded_wav_in_slot(
1423 &mut self,
1424 audio: &mut AudioHub,
1425 slot: usize,
1426 display_name: &str,
1427 wav: Vec<u8>,
1428 loop_flag: bool,
1429 ) -> Result<()> {
1430 self.inner
1431 .play_decoded_wav_in_slot(audio, slot, display_name, wav, loop_flag)
1432 }
1433
1434 pub fn stop(&mut self, fade_time_ms: Option<i64>) -> Result<()> {
1435 self.inner.stop_all(fade_time_ms)
1436 }
1437
1438 pub fn stop_slot(&mut self, slot: usize, fade_time_ms: Option<i64>) -> Result<()> {
1439 self.inner.stop_slot(slot, fade_time_ms)
1440 }
1441
1442 pub fn is_playing_any(&mut self) -> bool {
1443 self.inner.is_playing_any()
1444 }
1445
1446 pub fn is_playing_slot(&mut self, slot: usize) -> bool {
1447 self.inner.is_playing_slot(slot)
1448 }
1449
1450 pub fn volume_raw(&self) -> u8 {
1451 self.inner.volume_raw()
1452 }
1453
1454 pub fn set_volume_raw(&mut self, audio: &mut AudioHub, volume_raw: u8) -> Result<()> {
1455 self.inner.set_volume_raw(audio, volume_raw)
1456 }
1457
1458 pub fn set_volume_raw_fade(
1459 &mut self,
1460 audio: &mut AudioHub,
1461 volume_raw: u8,
1462 fade_ms: i64,
1463 ) -> Result<()> {
1464 self.inner.set_volume_raw_fade(audio, volume_raw, fade_ms)
1465 }
1466
1467 pub fn last_name(&self) -> Option<&str> {
1468 self.inner.last_name_slot(0)
1469 }
1470}