use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::Utc;
use clap::{Args as ClapArgs, ValueEnum};
use futures::stream::{self, Stream, StreamExt};
use scrybe_application::recording::{
CaptureCapability, CaptureRegistry, CaptureSource, CaptureSupport, NotesBackend,
RecordingController, RecordingOverrides, RecordingPlan, RecordingSnapshot, RecordingState,
StopAcceptance, StopSource, SystemBackend, TranscriptionModel,
};
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
use scrybe_capture_mac::{input_devices, InputDevice, MacCapture, NativeMicCapture, SckCapture};
#[cfg(all(feature = "mic-capture", not(feature = "system-capture-mac")))]
use scrybe_capture_mic::MicCapture;
#[cfg(feature = "mic-capture")]
use scrybe_core::capture::AudioCapture;
use scrybe_core::error::CaptureError;
use scrybe_core::session::SessionProgress;
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
use scrybe_core::storage::session_folder_name;
use scrybe_core::types::{AudioFrame, ConsentMode, SessionId, SpeakerLabel};
use tokio::sync::watch;
use crate::prompter::TtyPrompter;
use crate::runtime::{application, config_service};
#[derive(ClapArgs, Clone, Debug)]
pub struct Args {
#[arg(long)]
pub title: Option<String>,
#[arg(long)]
pub root: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub yes: bool,
#[arg(long, value_enum)]
pub consent: Option<ConsentModeArg>,
#[arg(long, default_value_t = 5)]
pub synthetic_secs: u64,
#[arg(long, value_enum)]
pub source: Option<CaptureSourceArg>,
#[arg(long)]
pub input_device: Option<String>,
#[arg(long, value_enum)]
pub system_backend: Option<SystemBackendArg>,
#[arg(long, conflicts_with = "sherpa_model")]
pub whisper_model: Option<PathBuf>,
#[arg(long, conflicts_with = "whisper_model")]
pub sherpa_model: Option<PathBuf>,
#[arg(long, value_enum)]
pub llm: Option<LlmBackendArg>,
#[arg(long, default_value_t = false)]
pub shell: bool,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum ConsentModeArg {
Quick,
Notify,
Announce,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum CaptureSourceArg {
#[default]
Synthetic,
Mic,
#[value(name = "mic+system")]
MicSystem,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum SystemBackendArg {
#[default]
Sck,
Tap,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum LlmBackendArg {
#[default]
Stub,
#[value(name = "openai-compat")]
OpenAiCompat,
}
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
enum SystemCapture {
Sck(SckCapture),
Tap(MacCapture),
}
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
impl SystemCapture {
fn new(backend: SystemBackend) -> Self {
match backend {
SystemBackend::Sck => Self::Sck(SckCapture::new()),
SystemBackend::Tap => Self::Tap(MacCapture::new()),
}
}
fn start(&mut self) -> Result<()> {
match self {
Self::Sck(capture) => capture.start().map_err(Into::into),
Self::Tap(capture) => capture.start().map_err(Into::into),
}
}
fn frames(&self) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
match self {
Self::Sck(capture) => Box::pin(capture.frames()),
Self::Tap(capture) => Box::pin(capture.frames()),
}
}
fn stop(&mut self) -> Result<()> {
match self {
Self::Sck(capture) => capture.stop().map_err(Into::into),
Self::Tap(capture) => capture.stop().map_err(Into::into),
}
}
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
const TAP_STARTUP_ACTIVITY_WINDOW: Duration = Duration::from_millis(1_500);
#[cfg(any(test, feature = "mic-capture"))]
use scrybe_application::recording::CaptureFrames as CaptureFrameStream;
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
async fn start_system_capture(
selected: SystemBackend,
) -> Result<(SystemCapture, CaptureFrameStream, Option<&'static str>)> {
let mut capture = SystemCapture::new(selected);
if let Err(error) = capture.start() {
let Some(backend) = fallback_backend(selected) else {
return Err(error);
};
let mut fallback = SystemCapture::new(backend);
fallback.start().context(
"Core Audio Tap failed to start and ScreenCaptureKit could not start either",
)?;
let frames = fallback.frames();
return Ok((
fallback,
frames,
Some("system capture switched from tap to sck after tap start failure"),
));
}
let frames = capture.frames();
if fallback_backend(selected).is_some() {
let (active, frames) = tap_produces_nonzero_frames(frames).await;
if !active {
capture
.stop()
.context("stopping silent Core Audio Tap before fallback")?;
let mut fallback = SystemCapture::new(SystemBackend::Sck);
fallback.start().context(
"Core Audio Tap had no startup activity and ScreenCaptureKit could not start",
)?;
let frames = fallback.frames();
return Ok((
fallback,
frames,
Some("system capture switched from tap to sck after no tap startup activity"),
));
}
return Ok((capture, frames, None));
}
Ok((capture, frames, None))
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
async fn tap_produces_nonzero_frames(mut frames: CaptureFrameStream) -> (bool, CaptureFrameStream) {
let deadline = tokio::time::Instant::now() + TAP_STARTUP_ACTIVITY_WINDOW;
let mut buffered = Vec::new();
let mut active = false;
loop {
match tokio::time::timeout_at(deadline, frames.next()).await {
Ok(Some(Ok(frame))) => {
active |= frame.samples.iter().any(|sample| *sample != 0.0);
buffered.push(Ok(frame));
if active {
break;
}
}
Ok(Some(Err(error))) => {
buffered.push(Err(error));
break;
}
Ok(None) | Err(_) => break,
}
}
(
active,
Box::pin(futures::stream::iter(buffered).chain(frames)),
)
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
const fn fallback_backend(selected: SystemBackend) -> Option<SystemBackend> {
match selected {
SystemBackend::Tap => Some(SystemBackend::Sck),
SystemBackend::Sck => None,
}
}
impl From<CaptureSourceArg> for CaptureSource {
fn from(value: CaptureSourceArg) -> Self {
match value {
CaptureSourceArg::Synthetic => Self::Synthetic,
CaptureSourceArg::Mic => Self::Mic,
CaptureSourceArg::MicSystem => Self::MicSystem,
}
}
}
impl From<SystemBackendArg> for SystemBackend {
fn from(value: SystemBackendArg) -> Self {
match value {
SystemBackendArg::Sck => Self::Sck,
SystemBackendArg::Tap => Self::Tap,
}
}
}
impl From<LlmBackendArg> for NotesBackend {
fn from(value: LlmBackendArg) -> Self {
match value {
LlmBackendArg::Stub => Self::Stub,
LlmBackendArg::OpenAiCompat => Self::OpenAiCompat,
}
}
}
impl From<ConsentModeArg> for ConsentMode {
fn from(value: ConsentModeArg) -> Self {
match value {
ConsentModeArg::Quick => Self::Quick,
ConsentModeArg::Notify => Self::Notify,
ConsentModeArg::Announce => Self::Announce,
}
}
}
#[must_use]
pub fn overrides_from(args: &Args) -> RecordingOverrides {
RecordingOverrides {
title: args.title.clone(),
root: args.root.clone(),
source: args.source.map(Into::into),
system_backend: args.system_backend.map(Into::into),
input_device: args.input_device.clone(),
whisper_model: args.whisper_model.clone(),
sherpa_model: args.sherpa_model.clone(),
notes: args.llm.map(Into::into),
consent: args.consent.map(Into::into),
}
}
#[must_use]
pub const fn build_support() -> CaptureSupport {
CaptureSupport {
capture: if cfg!(all(feature = "mic-capture", feature = "system-capture-mac")) {
CaptureCapability::MicrophoneAndSystemAudio
} else if cfg!(feature = "mic-capture") {
CaptureCapability::Microphone
} else {
CaptureCapability::SyntheticOnly
},
transcription_model: cfg!(any(feature = "whisper-local", feature = "stt-sherpa")),
notes_provider: cfg!(feature = "llm-openai-compat"),
}
}
#[must_use]
#[allow(
clippy::missing_const_for_fn,
reason = "const under one feature selection only; the enumerating build allocates"
)]
pub fn available_devices() -> Option<Vec<String>> {
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
{
input_devices().ok().map(|devices| {
devices
.into_iter()
.map(|device| device.uid)
.collect::<Vec<_>>()
})
}
#[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
{
None
}
}
pub fn begin_recording(controller: &RecordingController, args: &Args) -> Result<RecordingPlan> {
let devices = available_devices();
scrybe_application::recording::begin(
controller,
&config_service()?,
home_directory().as_deref(),
build_support(),
devices.as_deref(),
&overrides_from(args),
)
.map_err(|refusal| {
let hints: Vec<String> = [rebuild_hint(&refusal), model_rebuild_hint(&refusal)]
.into_iter()
.flatten()
.collect();
let mut error = anyhow::Error::from(refusal.error);
for hint in hints {
error = error.context(hint);
}
error
})
}
fn rebuild_hint(refusal: &scrybe_application::recording::Refusal) -> Option<String> {
let blocked_on_capture = refusal
.report
.blocking()
.iter()
.any(|finding| finding.check == scrybe_application::recording::PreflightCheck::Capture);
if !blocked_on_capture {
return None;
}
match refusal.plan.as_ref()?.source {
CaptureSource::Synthetic => None,
CaptureSource::Mic => Some(
"--source mic requires the binary to be built with --features mic-capture; \
this binary was built without it"
.to_string(),
),
CaptureSource::MicSystem => Some(
"--source mic+system requires the binary to be built with both \
--features mic-capture and --features system-capture-mac; \
this binary was built without one or both"
.to_string(),
),
}
}
fn model_rebuild_hint(refusal: &scrybe_application::recording::Refusal) -> Option<String> {
let blocked_on_model = refusal
.report
.blocking()
.iter()
.any(|finding| finding.check == scrybe_application::recording::PreflightCheck::Model);
if !blocked_on_model {
return None;
}
match &refusal.plan.as_ref()?.transcription {
TranscriptionModel::Stub => None,
TranscriptionModel::Whisper(_) => Some(
"--whisper-model requires the binary to be built with \
--features whisper-local; this binary was built without it"
.to_string(),
),
TranscriptionModel::Sherpa(_) => Some(
"--sherpa-model requires the binary to be built with \
--features stt-sherpa; this binary was built without it"
.to_string(),
),
}
}
fn home_directory() -> Option<PathBuf> {
directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
}
pub async fn run(args: Args) -> Result<()> {
let (stop_tx, stop_rx) = watch::channel(false);
let controller = Arc::clone(application(args.root.as_deref())?.recording());
let plan = begin_recording(&controller, &args)?;
let signal_controller = Arc::clone(&controller);
let signal_handle = tokio::spawn(monitor_signals(move || {
if signal_controller.request_stop(StopSource::Signal) == StopAcceptance::Accepted {
let _ = stop_tx.send(true);
}
}));
let result = run_with_stop(args, plan, stop_rx, Some(Arc::clone(&controller))).await;
signal_handle.abort();
settle(&controller, result.as_ref().err());
result
}
pub const RECORDING_FAILURE_SUMMARY: &str = "recording session failed";
fn settle(controller: &RecordingController, failure: Option<&anyhow::Error>) {
if let Some(error) = failure {
settle_failure(controller, error);
} else {
if controller.snapshot().state == RecordingState::Recording {
if let Err(conflict) = controller.begin_saving() {
tracing::debug!(%conflict, "recording controller could not enter saving");
}
}
if let Err(conflict) = controller.complete() {
tracing::debug!(%conflict, "recording controller could not complete");
}
}
if let Err(conflict) = controller.acknowledge() {
tracing::debug!(%conflict, "recording controller could not settle to idle");
}
}
fn settle_failure(
controller: &RecordingController,
error: &anyhow::Error,
) -> Option<RecordingSnapshot> {
tracing::error!(%error, "recording session failed");
match controller.fail(RECORDING_FAILURE_SUMMARY) {
Ok(snapshot) => Some(snapshot),
Err(conflict) => {
tracing::debug!(%conflict, "recording failure arrived in a state that cannot fail");
None
}
}
}
#[cfg(feature = "mic-capture")]
fn start_registered_capture<T>(registry: &CaptureRegistry, capture: T) -> Result<CaptureFrameStream>
where
T: AudioCapture,
{
let capture = registry.register(capture);
let mut capture = capture
.lock()
.map_err(|_| anyhow::anyhow!("capture registry adapter mutex poisoned"))?;
capture.start()?;
Ok(Box::pin(capture.frames()))
}
#[allow(clippy::too_many_lines)]
pub async fn run_with_stop(
args: Args,
plan: RecordingPlan,
stop_rx: watch::Receiver<bool>,
controller: Option<Arc<RecordingController>>,
) -> Result<()> {
let cfg = config_service()?.load()?;
let root = plan.root.clone();
tokio::fs::create_dir_all(&root)
.await
.with_context(|| format!("creating storage root {}", root.display()))?;
let auto_accept = args.yes || std::env::var("SCRYBE_CONSENT_AUTO_ACCEPT").as_deref() == Ok("1");
let prompter = TtyPrompter::new(auto_accept);
let source = plan.source;
let system_backend = plan.system_backend;
#[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
let _ = system_backend;
let id = SessionId::new();
let user = std::env::var("USER").unwrap_or_else(|_| "scrybe-user".into());
let started_at = Utc::now();
let capture_registry = CaptureRegistry::default();
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
let selected_input = match source {
CaptureSource::Synthetic => None,
CaptureSource::Mic | CaptureSource::MicSystem => {
let device = resolve_macos_input_device(plan.input_device.as_deref())?;
eprintln!("scrybe: input: {} ({})", device.name, device.uid);
Some(device)
}
};
let registry_for_stop = capture_registry.clone();
let stop_future = Box::pin(async move {
wait_for_stop(stop_rx).await;
if let Err(error) = registry_for_stop.stop_all() {
tracing::error!(error = %error, "stopping registered capture failed");
}
});
let stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> = match source
{
CaptureSource::Synthetic => {
Box::pin(synthetic_capture_stream(args.synthetic_secs).take_until(stop_future))
}
CaptureSource::Mic => {
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
{
let device = selected_input
.as_ref()
.context("resolved microphone missing for mic capture")?;
let stream = start_registered_capture(
&capture_registry,
NativeMicCapture::new(device.uid.clone(), plan.aec),
)
.with_context(|| {
format!(
"opening selected Core Audio input {} ({})",
device.name, device.uid
)
})?;
Box::pin(stream.take_until(stop_future))
}
#[cfg(all(feature = "mic-capture", not(feature = "system-capture-mac")))]
{
if plan.input_device.is_some() {
anyhow::bail!(
"--input-device requires a macOS build with --features \
mic-capture,system-capture-mac"
);
}
let stream = start_registered_capture(&capture_registry, MicCapture::new())
.context(
"opening default input device (grant Microphone permission \
in System Settings → Privacy & Security if prompted)",
)?;
Box::pin(stream.take_until(stop_future))
}
#[cfg(not(feature = "mic-capture"))]
{
anyhow::bail!(
"--source mic requires the binary to be built with --features mic-capture; \
this binary was built without it"
);
}
}
CaptureSource::MicSystem => {
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
{
use futures::stream;
let (mut system_capture, system_frames, fallback_note) =
start_system_capture(system_backend).await?;
if let Some(note) = fallback_note {
tracing::warn!(system_backend = "sck", "{note}");
write_capture_diagnostic(&root, started_at, id, args.title.as_deref(), note)?;
}
capture_registry.register_stopper(move || {
system_capture.stop().map_err(|error| {
CaptureError::Platform(Box::new(std::io::Error::other(error.to_string())))
})
});
let device = selected_input
.as_ref()
.context("resolved microphone missing for mic+system capture")?;
let mic_frames = start_registered_capture(
&capture_registry,
NativeMicCapture::new(device.uid.clone(), plan.aec),
)
.with_context(|| {
format!(
"opening selected Core Audio input {} ({})",
device.name, device.uid
)
})?;
Box::pin(stream::select(mic_frames, system_frames).take_until(stop_future))
}
#[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
{
anyhow::bail!(
"--source mic+system requires the binary to be built with both \
--features mic-capture and --features system-capture-mac; \
this binary was built without one or both"
);
}
}
};
let stream = capture_liveness_watchdog(stream, capture_registry.clone());
let outputs = scrybe_application::recording::run(
scrybe_application::recording::RecordingRun {
plan: &plan,
config: &cfg,
id,
started_at,
user,
prompter: &prompter,
controller,
on_progress: None,
on_session_event: Some(Arc::new(print_session_progress)),
},
stream,
)
.await
.context("running session");
if let Err(error) = capture_registry.stop_all() {
tracing::error!(error = %error, "stopping capture after the session ended failed");
}
let outputs = outputs?;
println!(
"scrybe record: session {} written to {}",
id,
outputs.folder.display()
);
println!(" transcript: {}", outputs.transcript_path.display());
println!(" notes: {}", outputs.notes_path.display());
println!(" meta: {}", outputs.meta_path.display());
if outputs.audio_path.exists() {
println!(" audio: {}", outputs.audio_path.display());
}
let playback_path = outputs.folder.join("playback.opus");
if playback_path.exists() {
println!(" playback: {}", playback_path.display());
}
Ok(())
}
fn print_session_progress(event: SessionProgress) {
match event {
SessionProgress::Recording => {
eprintln!("scrybe: recording; press Ctrl-C to stop");
}
SessionProgress::TranscriptAccepted(attributed) => {
let elapsed_secs = attributed.chunk.start_ms / 1_000;
let minutes = elapsed_secs / 60;
let seconds = elapsed_secs % 60;
let speaker = match &attributed.speaker {
SpeakerLabel::Me => "Me",
SpeakerLabel::Them => "Them",
SpeakerLabel::Named(name) => name,
SpeakerLabel::Unknown => "Unknown",
};
let text = attributed.chunk.text.trim();
if !text.is_empty() {
println!("[{minutes:02}:{seconds:02}] {speaker}: {text}");
}
}
SessionProgress::FinalizingTranscript { pending_chunks } => {
eprintln!(
"scrybe: finalizing transcript ({pending_chunks} pending chunk{})",
if pending_chunks == 1 { "" } else { "s" }
);
}
SessionProgress::EncodingAudio => {
eprintln!("scrybe: encoding audio artifacts");
}
SessionProgress::GeneratingNotes { groups } => {
eprintln!(
"scrybe: generating notes ({groups} request group{})",
if groups == 1 { "" } else { "s" }
);
}
SessionProgress::WritingMetadata => {
eprintln!("scrybe: writing session metadata");
}
}
}
const CAPTURE_LIVENESS_TIMEOUT: Duration = Duration::from_secs(30);
fn capture_liveness_watchdog(
stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>>,
capture_registry: CaptureRegistry,
) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
capture_liveness_watchdog_with_timeout(stream, capture_registry, CAPTURE_LIVENESS_TIMEOUT)
}
fn capture_liveness_watchdog_with_timeout(
stream: Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>>,
capture_registry: CaptureRegistry,
timeout: Duration,
) -> Pin<Box<dyn Stream<Item = Result<AudioFrame, CaptureError>> + Send>> {
Box::pin(stream::unfold(
(stream, capture_registry, false),
move |(mut stream, capture_registry, stopped)| async move {
if stopped {
return None;
}
match tokio::time::timeout(timeout, stream.next()).await {
Ok(Some(frame)) => Some((frame, (stream, capture_registry, false))),
Ok(None) => None,
Err(_) => {
if let Err(error) = capture_registry.stop_all() {
tracing::error!(error = %error, "stopping stalled capture failed");
}
let error = CaptureError::Platform(Box::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"capture liveness watchdog expired after 30 seconds without a frame",
)));
Some((Err(error), (stream, capture_registry, true)))
}
}
},
))
}
#[cfg(any(test, all(feature = "mic-capture", feature = "system-capture-mac")))]
fn write_capture_diagnostic(
root: &std::path::Path,
started_at: chrono::DateTime<Utc>,
id: SessionId,
title: Option<&str>,
note: &str,
) -> Result<()> {
let folder = root.join(session_folder_name(
started_at,
title.unwrap_or("untitled"),
id,
));
std::fs::create_dir_all(&folder)
.with_context(|| format!("creating capture diagnostic folder {}", folder.display()))?;
std::fs::write(folder.join("capture.log"), format!("{note}\n"))
.context("writing system-capture fallback diagnostic")
}
#[cfg(all(feature = "mic-capture", feature = "system-capture-mac"))]
fn resolve_macos_input_device(requested_uid: Option<&str>) -> Result<InputDevice> {
let devices = input_devices()
.map_err(anyhow::Error::from)
.context("enumerating macOS Core Audio input devices")?;
if let Some(uid) = requested_uid {
return devices
.into_iter()
.find(|device| device.uid == uid)
.with_context(|| format!("configured Core Audio input device `{uid}` was not found"));
}
devices
.into_iter()
.find(|device| device.is_default)
.context("macOS has no default Core Audio input device")
}
async fn wait_for_stop(mut stop_rx: watch::Receiver<bool>) {
let _ = stop_rx.wait_for(|stopped| *stopped).await;
}
pub async fn monitor_signals<F>(mut request_graceful_stop: F)
where
F: FnMut() + Send + 'static,
{
#[cfg(unix)]
let Ok(mut sigterm) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) else {
tracing::error!("installing SIGTERM listener failed");
return;
};
let mut graceful_requested = false;
loop {
#[cfg(unix)]
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
#[cfg(not(unix))]
if tokio::signal::ctrl_c().await.is_err() {
return;
}
if graceful_requested {
std::process::exit(130);
}
graceful_requested = true;
request_graceful_stop();
}
}
#[allow(clippy::cast_precision_loss)]
fn synthetic_capture_stream(
seconds: u64,
) -> impl Stream<Item = Result<AudioFrame, CaptureError>> + Send + Unpin {
let frame_delay = synthetic_frame_delay();
Box::pin(
scrybe_application::recording::synthetic_frames(seconds).then(move |frame| async move {
if !frame_delay.is_zero() {
tokio::time::sleep(frame_delay).await;
}
frame
}),
)
}
fn synthetic_frame_delay() -> Duration {
std::env::var("SCRYBE_TEST_SYNTHETIC_FRAME_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map_or(Duration::ZERO, Duration::from_millis)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use scrybe_core::config::{Config, RecordConfig, RECORD_SOURCE_MIC, RECORD_SYSTEM_BACKEND_TAP};
#[tokio::test]
async fn test_capture_liveness_watchdog_stops_adapters_and_reports_timeout() {
let registry = CaptureRegistry::default();
let stops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed_stops = Arc::clone(&stops);
registry.register_stopper(move || {
observed_stops.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
});
let stalled = Box::pin(stream::pending::<Result<AudioFrame, CaptureError>>());
let mut watchdog =
capture_liveness_watchdog_with_timeout(stalled, registry, Duration::from_millis(10));
let error = watchdog
.next()
.await
.expect("watchdog error")
.expect_err("timeout error");
assert_eq!(error.to_string(), "platform API error: capture liveness watchdog expired after 30 seconds without a frame");
assert_eq!(stops.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(watchdog.next().await.is_none());
}
#[test]
fn test_consent_mode_arg_quick_maps_to_consent_mode_quick() {
let mode: ConsentMode = ConsentModeArg::Quick.into();
assert_eq!(mode, ConsentMode::Quick);
}
#[test]
fn test_consent_mode_arg_notify_maps_to_consent_mode_notify() {
let mode: ConsentMode = ConsentModeArg::Notify.into();
assert_eq!(mode, ConsentMode::Notify);
}
#[test]
fn test_consent_mode_arg_announce_maps_to_consent_mode_announce() {
let mode: ConsentMode = ConsentModeArg::Announce.into();
assert_eq!(mode, ConsentMode::Announce);
}
#[tokio::test]
async fn test_run_writes_session_artifacts_for_synthetic_capture() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
let dir = tempfile::tempdir().unwrap();
run(Args {
title: Some("synthetic".into()),
root: Some(dir.path().to_path_buf()),
yes: true,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::Synthetic),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
input_device: None,
whisper_model: None,
sherpa_model: None,
})
.await
.unwrap();
let mut entries = std::fs::read_dir(dir.path()).unwrap();
let session = entries
.next()
.expect("a session folder must exist")
.unwrap();
assert!(session.path().join("transcript.md").exists());
assert!(session.path().join("notes.md").exists());
assert!(session.path().join("meta.toml").exists());
}
#[tokio::test]
async fn test_wait_for_stop_resolves_when_sender_flips_to_true() {
let (tx, rx) = watch::channel(false);
let fut = wait_for_stop(rx);
tokio::pin!(fut);
assert!(
futures::poll!(&mut fut).is_pending(),
"wait_for_stop must remain pending while the flag is false"
);
tx.send(true).unwrap();
fut.await;
}
#[tokio::test]
async fn test_wait_for_stop_returns_immediately_when_sender_already_true() {
let (_tx, rx) = watch::channel(true);
wait_for_stop(rx).await;
}
#[tokio::test]
async fn test_wait_for_stop_resolves_when_sender_dropped() {
let (tx, rx) = watch::channel(false);
drop(tx);
wait_for_stop(rx).await;
}
#[tokio::test]
async fn test_run_auto_accepts_consent_via_env_var_when_yes_flag_is_false() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
std::env::set_var("SCRYBE_CONSENT_AUTO_ACCEPT", "1");
let dir = tempfile::tempdir().unwrap();
let result = run(Args {
title: Some("env-consent".into()),
root: Some(dir.path().to_path_buf()),
yes: false,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::Synthetic),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
input_device: None,
whisper_model: None,
sherpa_model: None,
})
.await;
std::env::remove_var("SCRYBE_CONSENT_AUTO_ACCEPT");
result.unwrap();
}
#[tokio::test]
async fn test_synthetic_capture_stream_emits_only_silence_for_zero_seconds() {
let stream = synthetic_capture_stream(0);
let frames: Vec<_> = stream.collect().await;
let speech_count = frames
.iter()
.filter(|f| {
f.as_ref()
.is_ok_and(|frame| frame.samples.iter().any(|s| s.abs() > 0.01))
})
.count();
assert_eq!(speech_count, 0);
}
#[tokio::test]
async fn test_run_completes_within_cold_start_budget_with_stub_providers() {
const COLD_START_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
let dir = tempfile::tempdir().unwrap();
let started = std::time::Instant::now();
run(Args {
title: Some("cold-start".into()),
root: Some(dir.path().to_path_buf()),
yes: true,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::Synthetic),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
input_device: None,
whisper_model: None,
sherpa_model: None,
})
.await
.unwrap();
let elapsed = started.elapsed();
assert!(
elapsed < COLD_START_BUDGET,
"cold-start exceeded {COLD_START_BUDGET:?}: actual {elapsed:?} \
— the stub-provider path should complete sub-second; investigate \
before bumping this budget"
);
}
#[test]
fn test_capture_source_arg_default_is_synthetic() {
assert_eq!(CaptureSourceArg::default(), CaptureSourceArg::Synthetic);
}
#[test]
fn test_capture_source_arg_parses_mic_plus_system_token() {
use clap::ValueEnum;
let arg = CaptureSourceArg::from_str("mic+system", false)
.expect("`mic+system` must parse to MicSystem");
assert_eq!(arg, CaptureSourceArg::MicSystem);
}
#[test]
fn test_capture_source_arg_rejects_typo_variants() {
use clap::ValueEnum;
for bad in ["mic-system", "mic_system", "system", "system+mic"] {
let r = CaptureSourceArg::from_str(bad, false);
assert!(r.is_err(), "{bad} must not parse to any variant; got {r:?}");
}
}
#[test]
fn test_system_backend_flag_overrides_record_config() {
let config = Config {
record: RecordConfig {
system_backend: RECORD_SYSTEM_BACKEND_TAP.to_string(),
..RecordConfig::default()
},
..Config::default()
};
let args = Args {
system_backend: Some(SystemBackendArg::Sck),
..bare_args()
};
let plan = RecordingPlan::resolve(&config, None, &overrides_from(&args)).unwrap();
assert_eq!(plan.system_backend, SystemBackend::Sck);
}
#[test]
fn test_system_backend_uses_valid_record_config_then_default() {
let tap = Config {
record: RecordConfig {
system_backend: RECORD_SYSTEM_BACKEND_TAP.to_string(),
..RecordConfig::default()
},
..Config::default()
};
let overrides = overrides_from(&bare_args());
assert_eq!(
RecordingPlan::resolve(&tap, None, &overrides)
.unwrap()
.system_backend,
SystemBackend::Tap
);
assert_eq!(
RecordingPlan::resolve(&Config::default(), None, &overrides)
.unwrap()
.system_backend,
SystemBackend::Sck
);
}
#[test]
fn test_tap_fallback_is_single_hop_to_sck() {
assert_eq!(
fallback_backend(SystemBackend::Tap),
Some(SystemBackend::Sck)
);
assert_eq!(fallback_backend(SystemBackend::Sck), None);
}
fn system_frame(samples: &[f32], timestamp_ns: u64) -> AudioFrame {
AudioFrame::from_slice(
samples,
1,
16_000,
timestamp_ns,
scrybe_core::types::FrameSource::System,
)
}
#[tokio::test]
async fn test_silent_tap_startup_falls_back_without_dropping_frames() {
let input = vec![
Ok(system_frame(&[0.0, 0.0], 0)),
Ok(system_frame(&[0.0, 0.0], 125_000)),
];
let (active, frames) =
tap_produces_nonzero_frames(Box::pin(futures::stream::iter(input))).await;
let observed: Vec<_> = frames
.map(|frame| {
let frame = frame.unwrap();
(frame.timestamp_ns, frame.samples.to_vec())
})
.collect()
.await;
assert!(!active);
assert_eq!(
observed,
vec![(0, vec![0.0, 0.0]), (125_000, vec![0.0, 0.0])]
);
}
#[tokio::test]
async fn test_active_tap_startup_preserves_buffered_and_remaining_frames() {
let input = vec![
Ok(system_frame(&[0.0, 0.0], 0)),
Ok(system_frame(&[0.25, 0.0], 125_000)),
Ok(system_frame(&[0.5, 0.0], 250_000)),
];
let (active, frames) =
tap_produces_nonzero_frames(Box::pin(futures::stream::iter(input))).await;
let observed: Vec<_> = frames
.map(|frame| {
let frame = frame.unwrap();
(frame.timestamp_ns, frame.samples.to_vec())
})
.collect()
.await;
assert!(active);
assert_eq!(
observed,
vec![
(0, vec![0.0, 0.0]),
(125_000, vec![0.25, 0.0]),
(250_000, vec![0.5, 0.0]),
]
);
}
#[test]
fn test_fallback_diagnostic_uses_initial_session_folder() {
let root = tempfile::tempdir().unwrap();
let started_at = Utc::now();
let id = SessionId::new();
write_capture_diagnostic(
root.path(),
started_at,
id,
Some("Initial title"),
"system capture switched from tap to sck after no tap startup activity",
)
.unwrap();
let folder = root
.path()
.join(session_folder_name(started_at, "Initial title", id));
assert_eq!(
std::fs::read_to_string(folder.join("capture.log")).unwrap(),
"system capture switched from tap to sck after no tap startup activity\n"
);
}
#[cfg(not(all(feature = "mic-capture", feature = "system-capture-mac")))]
#[tokio::test]
async fn test_run_with_mic_system_source_errors_without_both_features() {
std::env::set_var("SCRYBE_CONSENT_AUTO_ACCEPT", "1");
let dir = tempfile::tempdir().unwrap();
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
let result = run(Args {
title: Some("ms-feature-gate".into()),
root: Some(dir.path().to_path_buf()),
yes: true,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::MicSystem),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
whisper_model: None,
sherpa_model: None,
input_device: None,
})
.await;
let Err(err) = result else {
panic!("MicSystem without both features must error");
};
let msg = format!("{err:?}");
assert!(
msg.contains("--source mic+system")
&& msg.contains("mic-capture")
&& msg.contains("system-capture-mac"),
"error must name the source flag and both required features; got: {msg}"
);
}
#[cfg(not(feature = "whisper-local"))]
#[test]
fn test_a_whisper_model_without_the_feature_is_refused_with_the_rebuild_guidance() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
let directory = tempfile::tempdir().unwrap();
let controller = app_controller(directory.path());
let result = begin_recording(
&controller,
&Args {
root: Some(directory.path().to_path_buf()),
whisper_model: Some(directory.path().join("no-such-model.bin")),
..bare_args()
},
);
let Err(error) = result else {
panic!("a model without its runtime must be refused rather than silently stubbed");
};
let message = format!("{error:?}");
assert!(
message.contains("--whisper-model") && message.contains("--features whisper-local"),
"the refusal must name both the flag and the missing feature; got: {message}"
);
}
#[cfg(not(feature = "stt-sherpa"))]
#[test]
fn test_a_sherpa_model_without_the_feature_is_refused_with_the_rebuild_guidance() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
let directory = tempfile::tempdir().unwrap();
let controller = app_controller(directory.path());
let result = begin_recording(
&controller,
&Args {
root: Some(directory.path().to_path_buf()),
sherpa_model: Some(directory.path().join("no-such-model")),
..bare_args()
},
);
let Err(error) = result else {
panic!("a model without its runtime must be refused rather than silently stubbed");
};
let message = format!("{error:?}");
assert!(
message.contains("--sherpa-model") && message.contains("--features stt-sherpa"),
"the refusal must name both the flag and the missing feature; got: {message}"
);
}
#[test]
fn test_explicit_sherpa_model_overrides_configured_whisper_model() {
let config = Config {
record: RecordConfig {
source: RECORD_SOURCE_MIC.to_string(),
whisper_model: Some(PathBuf::from("/models/whisper.bin")),
..RecordConfig::default()
},
..Config::default()
};
let args = Args {
sherpa_model: Some(PathBuf::from("/models/sherpa")),
..bare_args()
};
let plan = RecordingPlan::resolve(&config, None, &overrides_from(&args)).unwrap();
assert_eq!(
plan.transcription,
TranscriptionModel::Sherpa(PathBuf::from("/models/sherpa"))
);
}
fn bare_args() -> Args {
Args {
title: None,
root: None,
yes: false,
consent: None,
synthetic_secs: 5,
source: None,
input_device: None,
system_backend: None,
whisper_model: None,
sherpa_model: None,
llm: None,
shell: false,
}
}
#[cfg(feature = "whisper-local")]
#[test]
fn test_a_partially_downloaded_whisper_model_is_refused_at_load() {
let dir = tempfile::tempdir().unwrap();
let partial = dir.path().join("ggml-tiny.bin.partial");
std::fs::write(&partial, b"unfinished download").unwrap();
let plan = RecordingPlan::resolve(
&Config::default(),
None,
&overrides_from(&Args {
whisper_model: Some(partial),
..bare_args()
}),
)
.unwrap();
let result = scrybe_application::recording::transcription(&plan, "en");
let Err(error) = result else {
panic!("an unfinished download must be rejected at construction");
};
let message = format!("{error:?}");
assert!(
message.contains("could not be loaded"),
"the refusal must name the loading step; got: {message}"
);
}
#[cfg(not(feature = "llm-openai-compat"))]
#[test]
fn test_openai_compat_without_the_feature_is_refused_rather_than_silently_stubbed() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("absent.toml"));
let directory = tempfile::tempdir().unwrap();
let controller = app_controller(directory.path());
let result = begin_recording(
&controller,
&Args {
root: Some(directory.path().to_path_buf()),
llm: Some(LlmBackendArg::OpenAiCompat),
..bare_args()
},
);
let Err(error) = result else {
panic!("openai-compat without the feature must be refused, not stubbed");
};
let message = format!("{error:?}");
assert!(
message.contains("openai-compat") && message.contains("no notes provider"),
"the refusal must name the backend and say the build carries none; got: {message}"
);
}
#[cfg(feature = "llm-openai-compat")]
#[test]
fn test_build_llm_provider_constructs_openai_compat_when_feature_enabled() {
let cfg = scrybe_core::config::LlmConfig {
provider: "ollama".into(),
model: "llama3.1:8b".into(),
..scrybe_core::config::LlmConfig::default()
};
let plan = RecordingPlan::resolve(
&Config::default(),
None,
&overrides_from(&Args {
llm: Some(LlmBackendArg::OpenAiCompat),
..bare_args()
}),
)
.unwrap();
let llm = scrybe_application::recording::notes(&plan, &cfg)
.expect("openai-compat branch must succeed when feature is on");
assert_eq!(
scrybe_core::providers::LlmProvider::name(&llm),
"ollama:llama3.1:8b"
);
}
#[test]
fn test_llm_backend_arg_default_is_stub() {
assert_eq!(LlmBackendArg::default(), LlmBackendArg::Stub);
}
fn app_controller(root: &std::path::Path) -> Arc<RecordingController> {
Arc::clone(application(Some(root)).unwrap().recording())
}
fn synthetic_args(root: PathBuf) -> Args {
Args {
title: Some("labelling".into()),
root: Some(root),
yes: true,
consent: Some(ConsentModeArg::Quick),
synthetic_secs: 1,
shell: false,
source: Some(CaptureSourceArg::Synthetic),
system_backend: None,
llm: Some(LlmBackendArg::Stub),
input_device: None,
whisper_model: None,
sherpa_model: None,
}
}
#[test]
fn test_a_preflight_failure_is_labelled_preflight_and_leaves_nothing_on_disk() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("not-a-directory");
std::fs::write(&blocker, b"").unwrap();
let root = blocker.join("sessions");
let controller = app_controller(dir.path());
let kinds = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorded = Arc::clone(&kinds);
controller.subscribe(Arc::new(move |event| {
if let Some(failure) = &event.failure {
recorded.lock().unwrap().push(failure.kind);
}
}));
let result = begin_recording(&controller, &synthetic_args(root.clone()));
assert!(result.is_err());
assert_eq!(
*kinds.lock().unwrap(),
vec![scrybe_application::recording::RecordingFailureKind::Preflight]
);
assert_eq!(controller.snapshot().state, RecordingState::Idle);
assert!(!root.exists(), "a failed preflight must write nothing");
assert!(
!blocker.is_dir(),
"a failed preflight must not have replaced the blocker"
);
}
#[tokio::test]
async fn test_a_finalization_failure_after_a_natural_capture_end_is_not_labelled_capture() {
let cfg_dir = tempfile::tempdir().unwrap();
std::env::set_var("SCRYBE_CONFIG", cfg_dir.path().join("no-such-config.toml"));
let dir = tempfile::tempdir().unwrap();
let controller = app_controller(dir.path());
let args = synthetic_args(dir.path().to_path_buf());
let plan = begin_recording(&controller, &args).unwrap();
let (_stop_tx, stop_rx) = watch::channel(false);
run_with_stop(args, plan, stop_rx, Some(Arc::clone(&controller)))
.await
.unwrap();
assert_eq!(controller.snapshot().state, RecordingState::Saving);
assert_eq!(
controller
.fail(RECORDING_FAILURE_SUMMARY)
.unwrap()
.failure
.map(|failure| failure.kind),
Some(scrybe_application::recording::RecordingFailureKind::Finalization)
);
}
#[tokio::test]
async fn test_a_capture_side_failure_is_not_labelled_finalization() {
let dir = tempfile::tempdir().unwrap();
let controller = app_controller(dir.path());
controller.begin_preparing().unwrap();
controller.mark_recording().unwrap();
let failed = controller.fail(RECORDING_FAILURE_SUMMARY).unwrap();
assert_eq!(
failed.failure.map(|failure| failure.kind),
Some(scrybe_application::recording::RecordingFailureKind::Capture)
);
}
#[test]
fn test_no_serialized_failure_carries_a_path_or_provider_name() {
let dir = tempfile::tempdir().unwrap();
let controller = app_controller(dir.path());
controller.begin_preparing().unwrap();
let error = anyhow::anyhow!("connection refused")
.context("loading notes model /Users/someone/Library/scrybe/qwen3-8b.gguf")
.context("resolving input device MacBook Pro Microphone (openai-compat)")
.context("creating storage root /Users/someone/Meetings/scrybe");
let snapshot = settle_failure(&controller, &error).expect("preparing can fail");
let encoded = serde_json::to_string(&snapshot).unwrap();
assert!(!encoded.contains('/'), "a path reached an event: {encoded}");
for secret in ["Users", "qwen3", "openai-compat", "MacBook", "gguf"] {
assert!(
!encoded.contains(secret),
"{secret} reached an event: {encoded}"
);
}
assert!(encoded.contains(RECORDING_FAILURE_SUMMARY));
}
}