pub mod apm;
mod capture;
mod loopback;
pub mod mixer;
pub mod resampler;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::ipc::Channel;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{Manager, Runtime};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("audio session already running")]
AlreadyRunning,
#[error("audio session not running")]
NotRunning,
#[error("audio device error: {0}")]
Device(String),
#[error("audio permission denied: {0}")]
Permission(String),
#[error("io: {0}")]
Io(String),
}
impl serde::Serialize for Error {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CaptureOptions {
pub loopback: bool,
pub processing: bool,
pub level_only: bool,
}
impl Default for CaptureOptions {
fn default() -> Self {
Self {
loopback: true,
processing: true,
level_only: false,
}
}
}
impl CaptureOptions {
pub(crate) fn uses_loopback(self) -> bool {
self.loopback && !self.level_only
}
pub(crate) fn uses_apm(self) -> bool {
self.processing && !self.level_only
}
pub(crate) fn emits_pcm(self) -> bool {
!self.level_only
}
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PcmSource {
Mic,
Loopback,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FrameEvent {
Pcm {
seq: u64,
source: PcmSource,
sample_rate: u32,
channels: u8,
samples_base64: String,
},
Level { mic_rms: f32, loopback_rms: f32 },
Failure { category: String, message: String },
}
#[derive(Default)]
pub struct AudioSession {
stop_token: Option<crossbeam_channel::Sender<()>>,
}
pub type SharedSession = Arc<Mutex<AudioSession>>;
#[tauri::command]
fn start(
state: tauri::State<'_, SharedSession>,
options: Option<CaptureOptions>,
channel: Channel<FrameEvent>,
) -> Result<(), Error> {
let mut session = state.lock();
if session.stop_token.is_some() {
return Err(Error::AlreadyRunning);
}
let (tx, rx) = crossbeam_channel::bounded::<()>(1);
session.stop_token = Some(tx);
drop(session);
let options = options.unwrap_or_default();
std::thread::Builder::new()
.name("system-audio".into())
.spawn(move || {
if let Err(err) = capture::run(options, channel.clone(), rx) {
let category = match &err {
Error::Permission(_) => "permission",
Error::Device(_) => "device",
Error::Io(_) => "io",
Error::AlreadyRunning | Error::NotRunning => "lifecycle",
};
let _ = channel.send(FrameEvent::Failure {
category: category.into(),
message: err.to_string(),
});
}
})
.map_err(|e| Error::Io(e.to_string()))?;
Ok(())
}
#[tauri::command]
fn stop(state: tauri::State<'_, SharedSession>) -> Result<(), Error> {
let mut session = state.lock();
if let Some(tx) = session.stop_token.take() {
let _ = tx.send(());
Ok(())
} else {
Err(Error::NotRunning)
}
}
#[tauri::command]
fn permission_status() -> &'static str {
capture::check_mic_permission_status()
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("system-audio")
.invoke_handler(tauri::generate_handler![start, stop, permission_status])
.setup(|app, _api| {
app.manage(SharedSession::default());
#[cfg(target_os = "windows")]
{
use tauri::path::BaseDirectory;
for candidate in ["webrtc-apm.dll", "resources/webrtc-apm.dll"] {
if let Ok(path) = app.path().resolve(candidate, BaseDirectory::Resource) {
if path.exists() {
apm::set_lib_path(path.to_string_lossy().into_owned());
break;
}
}
}
}
Ok(())
})
.build()
}