sfx.rs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. /* This file is part of DarkFi (https://dark.fi)
  2. *
  3. * Copyright (C) 2020-2026 Dyne.org foundation
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU Affero General Public License as
  7. * published by the Free Software Foundation, either version 3 of the
  8. * License, or (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU Affero General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU Affero General Public License
  16. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. */
  18. //! UI sound effects played through rodio (cpal underneath).
  19. //!
  20. //! The audio device is opened lazily on the first sound played. If the
  21. //! device cannot be opened, sounds are silently disabled for the session.
  22. //! On Android the JavaVM/Activity context must be registered with
  23. //! `ndk_context` before cpal initializes its AAudio backend.
  24. //!
  25. //! On Android a started but idle stream keeps the audio pipeline and its
  26. //! system wakelock active, so the stream is paused once the sounds have
  27. //! finished and resumed when the next one plays.
  28. use rodio::Source;
  29. use std::{io::Cursor, sync::LazyLock};
  30. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  31. use std::{
  32. sync::mpsc::{self, RecvTimeoutError},
  33. time::Duration,
  34. };
  35. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  36. use crate::util::spawn_thread;
  37. macro_rules! e { ($($arg:tt)*) => { error!(target: "app::sfx", $($arg)*); } }
  38. static CLICK_OGA: &[u8] = include_bytes!("../data/sfx/click.oga");
  39. static COMMUP_OGA: &[u8] = include_bytes!("../data/sfx/commup.oga");
  40. static CLOAK_OGA: &[u8] = include_bytes!("../data/sfx/cloak.oga");
  41. /// Idle window after the last sound before the stream is paused.
  42. /// Pausing flushes buffered audio, so it must exceed the duration of
  43. /// the longest sound (cloak, ~3.4s).
  44. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  45. const IDLE_MARGIN: Duration = Duration::from_secs(6);
  46. struct Sfx {
  47. /// Keeps the output stream alive for the process lifetime
  48. _output: rodio::MixerDeviceSink,
  49. /// Shared mixer the sounds get appended to
  50. mixer: rodio::mixer::Mixer,
  51. /// Decoded click sound, cloned on every play
  52. click: rodio::buffer::SamplesBuffer,
  53. /// Decoded startup sound
  54. commup: rodio::buffer::SamplesBuffer,
  55. /// Used for showing p2p overlay
  56. cloak: rodio::buffer::SamplesBuffer,
  57. /// Signals playback activity to the idle reaper thread
  58. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  59. activity: mpsc::Sender<()>,
  60. }
  61. static SFX: LazyLock<Option<Sfx>> = LazyLock::new(|| match init() {
  62. Ok(sfx) => Some(sfx),
  63. Err(err) => {
  64. e!("Audio init failed, disabling sounds: {err}");
  65. None
  66. }
  67. });
  68. fn init() -> Result<Sfx, Box<dyn std::error::Error>> {
  69. #[cfg(target_os = "android")]
  70. init_android_context();
  71. let output = rodio::DeviceSinkBuilder::open_default_sink()?;
  72. let mixer = output.mixer().clone();
  73. let click = rodio::Decoder::try_from(Cursor::new(CLICK_OGA))?.record();
  74. let commup = rodio::Decoder::try_from(Cursor::new(COMMUP_OGA))?.record();
  75. let cloak = rodio::Decoder::try_from(Cursor::new(CLOAK_OGA))?.record();
  76. // Leaving the sound mixer on in Android will just hold a wakelock
  77. // and drain battery. It is playing a silent sound so we must pause
  78. // the stream.
  79. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  80. let activity = spawn_idle_reaper();
  81. Ok(Sfx {
  82. _output: output,
  83. mixer,
  84. click,
  85. commup,
  86. cloak,
  87. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  88. activity,
  89. })
  90. }
  91. /// Pauses the output stream once the idle window passes without playback,
  92. /// and resumes it when the next sound is played. A started but idle
  93. /// AAudio stream keeps the audio pipeline and its system wakelock open,
  94. /// so it must not stay running while nothing plays. Pausing flushes
  95. /// buffered audio, which is why the idle window always exceeds the
  96. /// longest sound. cpal's pause and play block on a state change with a
  97. /// timeout, so they run on this dedicated thread instead of the UI
  98. /// thread.
  99. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  100. fn spawn_idle_reaper() -> mpsc::Sender<()> {
  101. let (tx, rx) = mpsc::channel();
  102. spawn_thread("sfx-idle", move || {
  103. // The stream runs from creation, then alternates between running
  104. // while sounds keep arriving and paused once the idle window
  105. // elapses, so every play() lands on a paused stream and every
  106. // pause() on a running one.
  107. loop {
  108. loop {
  109. match rx.recv_timeout(IDLE_MARGIN) {
  110. // Another sound has been played so continue loop
  111. Ok(()) => {}
  112. // Idle timeout so pause the stream
  113. Err(RecvTimeoutError::Timeout) => {
  114. if let Some(sfx) = &*SFX {
  115. sfx._output.pause();
  116. }
  117. break;
  118. }
  119. // Receiver dropped which means this thread can exit
  120. Err(RecvTimeoutError::Disconnected) => return,
  121. }
  122. }
  123. match rx.recv() {
  124. // Sound is received for playing
  125. Ok(()) => {
  126. if let Some(sfx) = &*SFX {
  127. sfx._output.play();
  128. }
  129. }
  130. // Receiver dropped which means this thread can exit
  131. Err(_) => return,
  132. }
  133. }
  134. });
  135. tx
  136. }
  137. /// cpal's AAudio backend fetches the JavaVM and Activity from the
  138. /// `ndk-context` crate, which miniquad does not initialize itself.
  139. #[cfg(target_os = "android")]
  140. fn init_android_context() {
  141. use miniquad::native::android;
  142. unsafe {
  143. let env = crate::android::get_jni_env();
  144. let mut vm: *mut android::ndk_sys::JavaVM = std::ptr::null_mut();
  145. let get_java_vm = (**env).GetJavaVM.unwrap();
  146. assert_eq!(get_java_vm(env, &mut vm), 0);
  147. assert!(!vm.is_null());
  148. assert!(!android::ACTIVITY.is_null());
  149. ndk_context::initialize_android_context(vm as *mut _, android::ACTIVITY as *mut _);
  150. }
  151. }
  152. impl Sfx {
  153. fn play(&self, sound: &rodio::buffer::SamplesBuffer) {
  154. // Notify the reaper before appending, so the stream is resumed
  155. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  156. let _ = self.activity.send(());
  157. self.mixer.add(sound.clone());
  158. }
  159. }
  160. pub fn play_click() {
  161. if let Some(sfx) = &*SFX {
  162. sfx.play(&sfx.click);
  163. }
  164. }
  165. pub fn play_commup() {
  166. if let Some(sfx) = &*SFX {
  167. sfx.play(&sfx.commup);
  168. }
  169. }
  170. pub fn play_cloak() {
  171. if let Some(sfx) = &*SFX {
  172. sfx.play(&sfx.cloak);
  173. }
  174. }