sfx.rs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  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. static TICK_OGA: &[u8] = include_bytes!("../data/sfx/tick.oga");
  42. /// Idle window after the last sound before the stream is paused.
  43. /// Pausing flushes buffered audio, so it must exceed the duration of
  44. /// the longest sound (cloak, ~3.4s).
  45. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  46. const IDLE_MARGIN: Duration = Duration::from_secs(6);
  47. struct Sfx {
  48. /// Keeps the output stream alive for the process lifetime
  49. _output: rodio::MixerDeviceSink,
  50. /// Shared mixer the sounds get appended to
  51. mixer: rodio::mixer::Mixer,
  52. /// Decoded click sound, cloned on every play
  53. click: rodio::buffer::SamplesBuffer,
  54. /// Decoded startup sound
  55. commup: rodio::buffer::SamplesBuffer,
  56. /// Used for showing p2p overlay
  57. cloak: rodio::buffer::SamplesBuffer,
  58. /// Dropdown item hover blip
  59. tick: rodio::buffer::SamplesBuffer,
  60. /// Signals playback activity to the idle reaper thread
  61. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  62. activity: mpsc::Sender<()>,
  63. }
  64. static SFX: LazyLock<Option<Sfx>> = LazyLock::new(|| match init() {
  65. Ok(sfx) => Some(sfx),
  66. Err(err) => {
  67. e!("Audio init failed, disabling sounds: {err}");
  68. None
  69. }
  70. });
  71. fn init() -> Result<Sfx, Box<dyn std::error::Error>> {
  72. #[cfg(target_os = "android")]
  73. init_android_context();
  74. let output = rodio::DeviceSinkBuilder::open_default_sink()?;
  75. let mixer = output.mixer().clone();
  76. let click = rodio::Decoder::try_from(Cursor::new(CLICK_OGA))?.record();
  77. let commup = rodio::Decoder::try_from(Cursor::new(COMMUP_OGA))?.record();
  78. let cloak = rodio::Decoder::try_from(Cursor::new(CLOAK_OGA))?.record();
  79. let tick = rodio::Decoder::try_from(Cursor::new(TICK_OGA))?.record();
  80. // Leaving the sound mixer on in Android will just hold a wakelock
  81. // and drain battery. It is playing a silent sound so we must pause
  82. // the stream.
  83. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  84. let activity = spawn_idle_reaper();
  85. Ok(Sfx {
  86. _output: output,
  87. mixer,
  88. click,
  89. commup,
  90. cloak,
  91. tick,
  92. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  93. activity,
  94. })
  95. }
  96. /// Pauses the output stream once the idle window passes without playback,
  97. /// and resumes it when the next sound is played. A started but idle
  98. /// AAudio stream keeps the audio pipeline and its system wakelock open,
  99. /// so it must not stay running while nothing plays. Pausing flushes
  100. /// buffered audio, which is why the idle window always exceeds the
  101. /// longest sound. cpal's pause and play block on a state change with a
  102. /// timeout, so they run on this dedicated thread instead of the UI
  103. /// thread.
  104. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  105. fn spawn_idle_reaper() -> mpsc::Sender<()> {
  106. let (tx, rx) = mpsc::channel();
  107. spawn_thread("sfx-idle", move || {
  108. // The stream runs from creation, then alternates between running
  109. // while sounds keep arriving and paused once the idle window
  110. // elapses, so every play() lands on a paused stream and every
  111. // pause() on a running one.
  112. loop {
  113. loop {
  114. match rx.recv_timeout(IDLE_MARGIN) {
  115. // Another sound has been played so continue loop
  116. Ok(()) => {}
  117. // Idle timeout so pause the stream
  118. Err(RecvTimeoutError::Timeout) => {
  119. if let Some(sfx) = &*SFX {
  120. sfx._output.pause();
  121. }
  122. break;
  123. }
  124. // Receiver dropped which means this thread can exit
  125. Err(RecvTimeoutError::Disconnected) => return,
  126. }
  127. }
  128. match rx.recv() {
  129. // Sound is received for playing
  130. Ok(()) => {
  131. if let Some(sfx) = &*SFX {
  132. sfx._output.play();
  133. }
  134. }
  135. // Receiver dropped which means this thread can exit
  136. Err(_) => return,
  137. }
  138. }
  139. });
  140. tx
  141. }
  142. /// cpal's AAudio backend fetches the JavaVM and Activity from the
  143. /// `ndk-context` crate, which miniquad does not initialize itself.
  144. #[cfg(target_os = "android")]
  145. fn init_android_context() {
  146. use miniquad::native::android;
  147. unsafe {
  148. let env = crate::android::get_jni_env();
  149. let mut vm: *mut android::ndk_sys::JavaVM = std::ptr::null_mut();
  150. let get_java_vm = (**env).GetJavaVM.unwrap();
  151. assert_eq!(get_java_vm(env, &mut vm), 0);
  152. assert!(!vm.is_null());
  153. assert!(!android::ACTIVITY.is_null());
  154. ndk_context::initialize_android_context(vm as *mut _, android::ACTIVITY as *mut _);
  155. }
  156. }
  157. impl Sfx {
  158. fn play(&self, sound: &rodio::buffer::SamplesBuffer) {
  159. // Notify the reaper before appending, so the stream is resumed
  160. #[cfg(any(target_os = "android", feature = "emulate-android"))]
  161. let _ = self.activity.send(());
  162. self.mixer.add(sound.clone());
  163. }
  164. }
  165. pub fn play_click() {
  166. if let Some(sfx) = &*SFX {
  167. sfx.play(&sfx.click);
  168. }
  169. }
  170. pub fn play_commup() {
  171. if let Some(sfx) = &*SFX {
  172. sfx.play(&sfx.commup);
  173. }
  174. }
  175. pub fn play_cloak() {
  176. if let Some(sfx) = &*SFX {
  177. sfx.play(&sfx.cloak);
  178. }
  179. }
  180. pub fn play_tick() {
  181. if let Some(sfx) = &*SFX {
  182. sfx.play(&sfx.tick);
  183. }
  184. }