Skip to content

Commit a5dfbe0

Browse files
authored
Merge pull request voquill#495 from voquill/gk/mac-battery
Gk/mac battery
2 parents ef8572a + 4c2210a commit a5dfbe0

7 files changed

Lines changed: 232 additions & 85 deletions

File tree

‎apps/desktop/src-tauri/src/app.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,7 @@ pub fn build() -> tauri::Builder<tauri::Wry> {
192192

193193
app.manage(recorder);
194194

195-
// Pre-warm audio output for instant chime playback
196-
crate::system::audio_feedback::warm_audio_output();
195+
crate::system::audio_feedback::start_audio_thread();
197196

198197
crate::overlay::try_create_native_overlays(app_handle);
199198
}

‎apps/desktop/src-tauri/src/platform/macos/overlay.rs‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,27 @@
11
use std::sync::mpsc;
2-
use std::sync::Mutex;
32

43
use tauri::{Emitter, Manager};
54

65
use crate::domain::{OverlayPhase, PillWindowSize};
7-
use rust_macos_pill::ipc::{InMessage, OutMessage, Phase, Visibility};
6+
use rust_macos_pill::ipc::{InMessage, OutMessage, Phase, PillSender, Visibility};
87

98
struct MacosPill {
10-
sender: Mutex<mpsc::Sender<InMessage>>,
9+
sender: PillSender,
1110
}
1211

1312
impl MacosPill {
1413
fn send(&self, msg: InMessage) {
15-
if let Ok(sender) = self.sender.lock() {
16-
let _ = sender.send(msg);
17-
}
14+
self.sender.send(msg);
1815
}
1916
}
2017

2118
pub fn try_create_native_overlays(app: &tauri::AppHandle) -> bool {
22-
let (in_tx, in_rx) = mpsc::channel::<InMessage>();
2319
let (out_tx, out_rx) = mpsc::channel::<OutMessage>();
2420

2521
// Start the pill on the main thread (Tauri setup runs on main thread)
26-
rust_macos_pill::start(out_tx, in_rx);
22+
let sender = rust_macos_pill::start(out_tx);
2723

28-
let pill = std::sync::Arc::new(MacosPill {
29-
sender: Mutex::new(in_tx),
30-
});
24+
let pill = std::sync::Arc::new(MacosPill { sender });
3125
app.manage(pill);
3226

3327
start_out_reader(app.clone(), out_rx);
Lines changed: 79 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
1-
use rodio::{Decoder, OutputStream, Sink};
1+
use rodio::source::{Source, Zero};
2+
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
23
use std::io::Cursor;
3-
use std::sync::mpsc::{self, Sender};
4+
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
45
use std::sync::OnceLock;
56
use std::thread;
7+
use std::time::Duration;
8+
9+
/// How long the output stream stays open after the last chime. An open stream
10+
/// keeps CoreAudio running (and Bluetooth headphones awake) even while silent,
11+
/// so on macOS it is released when idle. 30s covers most dictations, so the
12+
/// stop chime usually reuses the start chime's stream. Other platforms keep the
13+
/// stream open for the life of the app; releasing it is untested there.
14+
#[cfg(target_os = "macos")]
15+
const OUTPUT_IDLE_TIMEOUT: Option<Duration> = Some(Duration::from_secs(30));
16+
#[cfg(not(target_os = "macos"))]
17+
const OUTPUT_IDLE_TIMEOUT: Option<Duration> = None;
18+
19+
/// Silence played ahead of a chime on a freshly opened stream. Bluetooth
20+
/// headphones drop the first moments of audio while waking, which would
21+
/// otherwise swallow the short chimes entirely.
22+
const WAKE_PREROLL: Duration = Duration::from_millis(250);
623

724
static START_RECORDING_CLIP: &[u8] = include_bytes!(concat!(
825
env!("CARGO_MANIFEST_DIR"),
@@ -34,61 +51,87 @@ static ALERT_WINDOWS_11_CLIP: &[u8] = include_bytes!(concat!(
3451
"/assets/audio/alert-windows-11.wav"
3552
));
3653

37-
/// Channel sender for the warm audio thread.
54+
/// Channel sender for the audio thread.
3855
static AUDIO_SENDER: OnceLock<Sender<AudioRequest>> = OnceLock::new();
3956

4057
enum AudioRequest {
4158
Play(&'static [u8]),
4259
}
4360

44-
/// Initialize a dedicated audio thread at app startup for instant chime playback.
45-
/// The thread keeps an OutputStream alive so we don't recreate it for each chime.
46-
pub fn warm_audio_output() {
61+
/// Start the dedicated chime playback thread. See `OUTPUT_IDLE_TIMEOUT` for
62+
/// how long its output stream stays open.
63+
pub fn start_audio_thread() {
4764
let (tx, rx) = mpsc::channel::<AudioRequest>();
4865

49-
// Store the sender for later use
5066
if AUDIO_SENDER.set(tx).is_err() {
5167
log::warn!("Audio sender already initialized");
5268
return;
5369
}
5470

55-
// Spawn the dedicated audio thread
5671
thread::spawn(move || {
57-
// Create the output stream once and keep it alive
58-
let (_stream, handle) = match OutputStream::try_default() {
59-
Ok(result) => {
60-
log::info!("Pre-warmed audio output stream");
61-
result
62-
}
63-
Err(err) => {
64-
log::error!("Failed to create audio output: {err}");
65-
// Still process requests, but they'll fail gracefully
66-
for request in rx {
67-
let AudioRequest::Play(bytes) = request;
68-
play_clip_fallback(bytes);
69-
}
70-
return;
71-
}
72+
let mut output = match OUTPUT_IDLE_TIMEOUT {
73+
Some(_) => None,
74+
None => open_output(),
7275
};
7376

74-
// Process play requests on this thread
75-
for request in rx {
77+
loop {
78+
let request = match OUTPUT_IDLE_TIMEOUT {
79+
Some(timeout) => rx.recv_timeout(timeout),
80+
None => rx.recv().map_err(RecvTimeoutError::from),
81+
};
7682
match request {
77-
AudioRequest::Play(bytes) => {
78-
if let Ok(sink) = Sink::try_new(&handle) {
79-
if let Ok(source) = Decoder::new(Cursor::new(bytes)) {
80-
sink.append(source);
81-
sink.sleep_until_end();
82-
}
83+
Ok(AudioRequest::Play(bytes)) => {
84+
let fresh = output.is_none();
85+
if fresh {
86+
output = open_output();
87+
}
88+
match &output {
89+
Some((_, handle)) => play_on(handle, bytes, fresh),
90+
None => play_clip_fallback(bytes),
8391
}
8492
}
93+
Err(RecvTimeoutError::Timeout) => {
94+
if output.take().is_some() {
95+
log::debug!("Released idle audio output stream");
96+
}
97+
}
98+
Err(RecvTimeoutError::Disconnected) => break,
8599
}
86100
}
87101
});
88102
}
89103

90-
/// Try to send a play request to the warm audio thread.
91-
fn try_warm_play(bytes: &'static [u8]) -> bool {
104+
fn open_output() -> Option<(OutputStream, OutputStreamHandle)> {
105+
OutputStream::try_default()
106+
.inspect_err(|err| log::error!("Failed to open default audio output stream: {err}"))
107+
.ok()
108+
}
109+
110+
/// `fresh` means the stream was just opened, so the device may still be waking.
111+
fn play_on(handle: &OutputStreamHandle, bytes: &'static [u8], fresh: bool) {
112+
let sink = match Sink::try_new(handle) {
113+
Ok(sink) => sink,
114+
Err(err) => {
115+
log::error!("Failed to create audio sink: {err}");
116+
return;
117+
}
118+
};
119+
let source = match Decoder::new(Cursor::new(bytes)) {
120+
Ok(source) => source,
121+
Err(err) => {
122+
log::error!("Failed to decode audio clip: {err}");
123+
return;
124+
}
125+
};
126+
if fresh {
127+
let silence = Zero::<f32>::new(source.channels(), source.sample_rate());
128+
sink.append(silence.take_duration(WAKE_PREROLL));
129+
}
130+
sink.append(source);
131+
sink.sleep_until_end();
132+
}
133+
134+
fn try_send_to_audio_thread(bytes: &'static [u8]) -> bool {
92135
if let Some(sender) = AUDIO_SENDER.get() {
93136
sender.send(AudioRequest::Play(bytes)).is_ok()
94137
} else {
@@ -121,8 +164,7 @@ pub fn play_alert_windows_11_clip() {
121164
}
122165

123166
fn play_clip(bytes: &'static [u8]) {
124-
// Try the warm audio thread first (instant)
125-
if try_warm_play(bytes) {
167+
if try_send_to_audio_thread(bytes) {
126168
return;
127169
}
128170

@@ -132,25 +174,8 @@ fn play_clip(bytes: &'static [u8]) {
132174

133175
fn play_clip_fallback(bytes: &'static [u8]) {
134176
thread::spawn(move || {
135-
if let Ok((stream, handle)) = OutputStream::try_default() {
136-
match Sink::try_new(&handle) {
137-
Ok(sink) => match Decoder::new(Cursor::new(bytes)) {
138-
Ok(source) => {
139-
sink.append(source);
140-
sink.sleep_until_end();
141-
}
142-
Err(err) => {
143-
log::error!("Failed to decode audio clip: {err}");
144-
}
145-
},
146-
Err(err) => {
147-
log::error!("Failed to create audio sink: {err}");
148-
}
149-
}
150-
151-
drop(stream);
152-
} else {
153-
log::error!("Failed to open default audio output stream");
177+
if let Some((_stream, handle)) = open_output() {
178+
play_on(&handle, bytes, true);
154179
}
155180
});
156181
}

0 commit comments

Comments
 (0)