Skip to content

Commit dcca286

Browse files
Complete macOS audio backend migration (#112)
* Complete macOS backend migration * Fix macOS Swift CI checks * Fix Swift concurrency test compilation
1 parent 05865eb commit dcca286

16 files changed

Lines changed: 7836 additions & 485 deletions

File tree

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,17 @@ The Rust boundary separates OS capture from transcription:
6767
PCM frame count (not packet count). The separate control queue is also
6868
bounded and cannot carry sample payloads. Microphone and system audio remain
6969
separate tracks.
70-
- The current Swift `WispAudioKit` remains the macOS compatibility adapter.
71-
Its existing `SessionResult` can be viewed as a backend-neutral
72-
`TranscriptEvent`, avoiding a risky capture rewrite in the first foundation
73-
change.
70+
- macOS production capture and transcription run through concrete
71+
`MacosCaptureBackend` and `MacosTranscriberBackend` adapters managed by
72+
`SessionOrchestrator`. Swift sends typed mic/system PCM into the same bounded,
73+
nonblocking capture queue used by native backends while retaining Ogg/Opus
74+
recording. Capture PCM reaches Rust first; `MacosTranscriberBackend::push`
75+
then submits only frames accepted by `SessionOrchestrator` back to
76+
`SpeechAnalyzer`. `MacosCaptureBackend` also
77+
has an independent recording-only constructor so another transcriber can
78+
consume the exposed PCM without requesting speech permission. Transcript and
79+
compatibility callbacks retain their original ordering. The legacy
80+
`Session` API remains available but is no longer the macOS desktop path.
7481

7582
This boundary is intentionally a foundation, not a claim that every backend is
7683
complete. Linux PipeWire capture, connecting Windows WASAPI frames to actual

apps/wisp-desktop/src/app.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ use std::time::Instant;
1515
use chrono::{DateTime, Utc};
1616
use wisp_audiokit::{
1717
Event, LocalModelStatus, Permission, PermissionStatus, RecognizerBackend, SessionConfig,
18-
SessionError, SessionResult, SourceLabel, local_model_spec, local_model_status,
18+
SessionError, SessionOptions, SessionResult, SourceLabel, TranscriptionPolicy,
19+
local_model_spec, local_model_status,
1920
};
2021
use wisp_core::{Session as StoredSession, SessionId};
2122
use wisp_lifecycle::{Phase, UpdateContext, ViewOwner, WorkerUpdate, can_replace_transcript};
@@ -151,6 +152,27 @@ impl Permissions {
151152
self.microphone.is_granted() && self.speech.is_granted()
152153
}
153154

155+
/// Whether the minimum permissions required by this session policy are
156+
/// available. macOS may record after Speech Recognition is explicitly
157+
/// denied/restricted only when policy permits record-only fallback.
158+
pub fn satisfies(
159+
self,
160+
policy: TranscriptionPolicy,
161+
) -> bool {
162+
if !self.microphone.is_granted() {
163+
return false;
164+
}
165+
if self.speech.is_granted() {
166+
return true;
167+
}
168+
cfg!(target_os = "macos")
169+
&& policy.allow_record_only
170+
&& matches!(
171+
self.speech,
172+
PermissionStatus::Denied | PermissionStatus::Restricted
173+
)
174+
}
175+
154176
pub fn set_status(
155177
&mut self,
156178
perm: Permission,
@@ -643,7 +665,8 @@ impl AppModel {
643665
}
644666

645667
pub fn setup_complete(&self) -> bool {
646-
self.permissions.all_granted() && self.setup.is_complete()
668+
let options: SessionOptions = self.setup.session_config("ja-JP").into();
669+
self.permissions.satisfies(options.transcription_policy()) && self.setup.is_complete()
647670
}
648671
}
649672

@@ -1035,4 +1058,28 @@ mod tests {
10351058
assert!(m.recent_log.len() <= 200);
10361059
assert!(m.recent_log.back().unwrap().contains("299"));
10371060
}
1061+
1062+
#[cfg(target_os = "macos")]
1063+
#[test]
1064+
fn macos_permission_gate_exposes_explicit_record_only_fallback() {
1065+
let permissions = Permissions {
1066+
microphone: PermissionStatus::Granted,
1067+
speech: PermissionStatus::Denied,
1068+
pending: None,
1069+
};
1070+
assert!(permissions.satisfies(TranscriptionPolicy::platform_default()));
1071+
assert!(!permissions.satisfies(TranscriptionPolicy {
1072+
allow_record_only: false,
1073+
..TranscriptionPolicy::platform_default()
1074+
}));
1075+
1076+
let undetermined = Permissions {
1077+
speech: PermissionStatus::Undetermined,
1078+
..permissions
1079+
};
1080+
assert!(
1081+
!undetermined.satisfies(TranscriptionPolicy::platform_default()),
1082+
"the onboarding screen should still offer the Speech prompt before denial"
1083+
);
1084+
}
10381085
}

apps/wisp-desktop/src/session_runner.rs

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1-
//! Owns the background OS thread that drives `wisp_audiokit::Session`.
1+
//! Owns the background OS thread that drives the platform audio session.
22
//!
3-
//! The Swift side calls back into Rust from arbitrary audio threads, and
4-
//! `Session::start()/stop()` block while async work runs underneath. To
5-
//! keep the GPUI main thread responsive we run the lifecycle on a worker
6-
//! thread and surface everything as a stream of `Update`s the UI polls.
3+
//! On macOS this is the backend-neutral orchestrator facade over the Swift
4+
//! capture/transcription callbacks. Start/stop block while async platform work
5+
//! runs underneath, so the lifecycle stays on a worker thread and surfaces
6+
//! everything as a stream of `Update`s the UI polls.
77
88
use std::path::PathBuf;
99
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel};
1010
use std::thread::JoinHandle;
1111
use std::time::{Duration, Instant};
1212

1313
use chrono::{DateTime, Utc};
14-
use wisp_audiokit::{Event, Session, SessionConfig, SessionError};
14+
#[cfg(target_os = "macos")]
15+
use wisp_audiokit::MacosSession as PlatformSession;
16+
#[cfg(not(target_os = "macos"))]
17+
use wisp_audiokit::Session as PlatformSession;
18+
use wisp_audiokit::{Event, SessionConfig, SessionError};
1519
use wisp_core::SessionId;
1620

1721
/// How often the running session checks for UI commands (Stop / Shutdown)
@@ -45,18 +49,24 @@ pub enum Command {
4549

4650
/// Updates the worker sends back to the UI.
4751
pub enum Update {
48-
/// `Session::start()` returned successfully and audio is flowing.
52+
/// The platform session started successfully and audio is flowing.
4953
Started(SessionStart),
5054
/// One transcription / log event from the session.
5155
Event { session_id: SessionId, event: Event },
52-
/// `Session::stop()` returned; the session has been torn down.
56+
/// The platform session stopped and has been torn down.
5357
Stopped { session_id: SessionId },
5458
/// Audio startup failed after constructing a session. Any partial capture
5559
/// has been stopped and its flushed events precede this update.
5660
StartFailed {
5761
session_id: SessionId,
5862
error: SessionError,
5963
},
64+
/// Capture/transcription failed after start; platform cleanup has already
65+
/// completed and partial audio/transcript must be finalized.
66+
RuntimeFailed {
67+
session_id: SessionId,
68+
error: SessionError,
69+
},
6070
/// Session construction failed before capture could start.
6171
Error {
6272
session_id: SessionId,
@@ -159,6 +169,7 @@ fn is_terminal_for(
159169
match update {
160170
Update::Stopped { session_id }
161171
| Update::StartFailed { session_id, .. }
172+
| Update::RuntimeFailed { session_id, .. }
162173
| Update::Error { session_id, .. } => *session_id == expected_session_id,
163174
Update::Started(_) | Update::Event { .. } => false,
164175
}
@@ -201,7 +212,7 @@ fn run_session(
201212
update_tx: &Sender<Update>,
202213
) {
203214
let session_id = session_start.session_id;
204-
let mut session = match Session::new_with_config(output_dir, config) {
215+
let mut session = match PlatformSession::new_with_config(output_dir, config) {
205216
Ok(s) => s,
206217
Err(e) => {
207218
let _ = update_tx.send(Update::Error {
@@ -245,34 +256,86 @@ fn run_session(
245256
session.set_microphone_muted(muted);
246257
},
247258
Ok(Command::Shutdown) | Err(TryRecvError::Disconnected) => {
248-
session.stop();
249-
let _ = update_tx.send(Update::Stopped { session_id });
259+
stop_and_publish(&mut session, session_id, update_tx);
250260
return;
251261
},
252262
Ok(Command::Start { .. }) | Err(TryRecvError::Empty) => {},
253263
}
254264
if let Some(event) = session.recv_timeout(CMD_POLL_INTERVAL) {
265+
#[cfg(target_os = "macos")]
266+
let terminal_error = session.take_runtime_failure();
255267
let _ = update_tx.send(Update::Event { session_id, event });
268+
#[cfg(target_os = "macos")]
269+
if let Some(error) = terminal_error {
270+
publish_runtime_failure_after_drain(
271+
|| session.try_recv(),
272+
session_id,
273+
error,
274+
update_tx,
275+
);
276+
return;
277+
}
256278
}
257279
}
258280

281+
stop_and_publish(&mut session, session_id, update_tx);
282+
}
283+
284+
fn stop_and_publish(
285+
session: &mut PlatformSession,
286+
session_id: SessionId,
287+
update_tx: &Sender<Update>,
288+
) {
259289
session.stop();
260290
// Drain whatever the analyzer flushed during stop().
261291
while let Some(event) = session.try_recv() {
262292
let _ = update_tx.send(Update::Event { session_id, event });
263293
}
294+
#[cfg(target_os = "macos")]
295+
if let Some(error) = session.take_runtime_failure() {
296+
let _ = update_tx.send(Update::RuntimeFailed { session_id, error });
297+
return;
298+
}
264299
let _ = update_tx.send(Update::Stopped { session_id });
265300
}
266301

302+
#[cfg(target_os = "macos")]
303+
fn publish_runtime_failure_after_drain(
304+
mut try_recv: impl FnMut() -> Option<Event>,
305+
session_id: SessionId,
306+
error: SessionError,
307+
update_tx: &Sender<Update>,
308+
) {
309+
// A strict transcriber failure performs graceful native cleanup before it
310+
// becomes terminal. SpeechAnalyzer may emit its last final while that
311+
// cleanup is running, so publish every flushed event before the terminal
312+
// update tells the UI to finalize persistence.
313+
while let Some(event) = try_recv() {
314+
let _ = update_tx.send(Update::Event { session_id, event });
315+
}
316+
let _ = update_tx.send(Update::RuntimeFailed { session_id, error });
317+
}
318+
267319
fn is_transcript_result(event: &Event) -> bool {
268320
matches!(event, Event::Result(_))
269321
}
270322

271323
#[cfg(test)]
272324
mod tests {
325+
#[cfg(target_os = "macos")]
326+
use std::collections::VecDeque;
327+
#[cfg(target_os = "macos")]
328+
use std::sync::mpsc::channel;
329+
330+
#[cfg(target_os = "macos")]
331+
use wisp_audiokit::SessionError;
273332
use wisp_audiokit::{Event, SessionResult, SourceLabel};
333+
#[cfg(target_os = "macos")]
334+
use wisp_core::SessionId;
274335

275336
use super::is_transcript_result;
337+
#[cfg(target_os = "macos")]
338+
use super::{Update, publish_runtime_failure_after_drain};
276339

277340
#[test]
278341
fn only_transcript_results_require_preserving_a_failed_start() {
@@ -288,4 +351,44 @@ mod tests {
288351
confidence_min: None,
289352
})));
290353
}
354+
355+
#[cfg(target_os = "macos")]
356+
#[test]
357+
fn runtime_failure_publishes_final_flushed_during_cleanup_first() {
358+
let session_id = SessionId::from(42);
359+
let final_result = Event::Result(SessionResult {
360+
source: SourceLabel::System,
361+
segment_id: 42,
362+
is_final: true,
363+
text: "cleanup final".into(),
364+
start_seconds: 2.0,
365+
end_seconds: 3.0,
366+
confidence_mean: Some(0.9),
367+
confidence_min: Some(0.8),
368+
});
369+
let mut cleanup_events = VecDeque::from([final_result.clone()]);
370+
let (tx, rx) = channel();
371+
372+
publish_runtime_failure_after_drain(
373+
|| cleanup_events.pop_front(),
374+
session_id,
375+
SessionError::Start("strict transcriber failure".into()),
376+
&tx,
377+
);
378+
379+
assert!(matches!(
380+
rx.recv().unwrap(),
381+
Update::Event {
382+
session_id: actual,
383+
event,
384+
} if actual == session_id && event == final_result
385+
));
386+
assert!(matches!(
387+
rx.recv().unwrap(),
388+
Update::RuntimeFailed {
389+
session_id: actual,
390+
..
391+
} if actual == session_id
392+
));
393+
}
291394
}

0 commit comments

Comments
 (0)