pub(crate) mod audio;
#[cfg(feature = "ui")]
mod entries;
#[cfg(feature = "ui")]
pub(crate) mod player;
#[cfg(all(feature = "capture", feature = "ui"))]
pub(crate) mod toggle;
#[cfg(feature = "ui")]
pub(crate) mod ui;
#[cfg(feature = "capture")]
use crate::audio::bt_profile;
#[cfg(feature = "capture")]
use crate::audio::monitor_capture::MonitorCapture;
#[cfg(feature = "capture")]
use crate::audio::pipewire_capture::PipeWireCapture;
#[cfg(all(feature = "capture", feature = "ui"))]
use crate::audio::recording_feedback::RecordingOverlayOptions;
#[cfg(feature = "capture")]
use crate::audio::recording_feedback::{
RecordingBadgeTeardown, RecordingFeedback, RecordingFeedbackOptions,
};
#[cfg(feature = "capture")]
use crate::audio::{AudioCapture, AudioWriter, OggOpusWriter, WavWriter};
#[cfg(feature = "capture")]
use crate::config::{AudioConfig, Config};
#[cfg(feature = "capture")]
use crate::error::TalkError;
#[cfg(feature = "capture")]
use chrono::Local;
#[cfg(feature = "capture")]
use std::io::SeekFrom;
#[cfg(feature = "capture")]
use std::path::{Path, PathBuf};
#[cfg(feature = "capture")]
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
#[cfg(feature = "capture")]
fn default_filename() -> String {
let now = Local::now();
now.format("%Y-%m-%dT%H-%M-%S%z.ogg").to_string()
}
#[cfg(feature = "capture")]
fn resolve_output_path(args: &[String], output_dir: &Path) -> Result<PathBuf, TalkError> {
match args.len() {
0 => {
let now = Local::now();
let year = now.format("%Y").to_string();
let month = now.format("%m").to_string();
Ok(output_dir.join(year).join(month).join(default_filename()))
}
1 => Ok(PathBuf::from(&args[0])),
_ => Err(TalkError::Audio(
"record command takes at most one argument (output file path)".to_string(),
)),
}
}
#[cfg(feature = "capture")]
pub fn parse_args(args: &[String]) -> Result<PathBuf, TalkError> {
let config = Config::load(None)?;
resolve_output_path(args, &config.output_dir)
}
#[cfg(feature = "capture")]
fn create_writer(path: &Path, config: AudioConfig) -> Result<Box<dyn AudioWriter>, TalkError> {
match path.extension().and_then(|e| e.to_str()) {
Some("wav") => Ok(Box::new(WavWriter::new(config))),
_ => Ok(Box::new(OggOpusWriter::new_for_recording(config)?)),
}
}
#[cfg(feature = "capture")]
pub struct RecordOpts {
pub args: Vec<String>,
pub monitor: bool,
pub no_sounds: bool,
pub no_boop: bool,
pub no_overlay: bool,
pub viz: Option<crate::config::VizMode>,
pub mono: bool,
pub no_bt_auto_switch: bool,
}
#[cfg(feature = "capture")]
pub async fn record(opts: RecordOpts) -> Result<(), TalkError> {
let mut interrupt =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.map_err(|error| TalkError::Audio(format!("Failed to listen for Ctrl+C: {error}")))?;
let output_path = parse_args(&opts.args)?;
let config_for_bt = Config::load(None).ok();
let bt_auto_switch_enabled = !opts.no_bt_auto_switch
&& config_for_bt
.as_ref()
.and_then(|c| c.audio.as_ref())
.map(|a| a.bt_auto_switch_enabled())
.unwrap_or(true);
let mut bt_guard = if !bt_auto_switch_enabled {
log::debug!("bt_profile: auto-switching disabled by config/flag");
bt_profile::HeadsetGuard::new(None)
} else {
if let Err(err) = bt_profile::recover_stale_profile() {
log::warn!("bt_profile: stale-recovery failed (non-fatal): {}", err);
}
let saved = match bt_profile::activate_headset() {
Ok(s) => s,
Err(err) => {
log::warn!("bt_profile: activate_headset failed (non-fatal): {}", err);
None
}
};
bt_profile::HeadsetGuard::new(saved)
};
let recording_config = config_for_bt
.as_ref()
.and_then(|c| c.recording.clone())
.unwrap_or_default();
let audio_config = recording_config.resolved();
log::info!(
"recording quality: {} Hz, {} channel(s), {} bps",
audio_config.sample_rate,
audio_config.channels,
audio_config.bitrate
);
let mut capture: Box<dyn AudioCapture> = if opts.monitor {
log::info!("recording with mic+monitor (PipeWire)");
Box::new(MonitorCapture::new(audio_config.clone()))
} else {
log::info!("recording with mic (PipeWire)");
Box::new(PipeWireCapture::new(audio_config.clone()))
};
let viz_mode = opts.viz.or_else(|| {
config_for_bt
.as_ref()
.and_then(|config| config.indicators.as_ref())
.and_then(|indicators| indicators.viz)
});
let boop_interval_ms = config_for_bt
.as_ref()
.and_then(|config| config.indicators.as_ref())
.map(|indicators| indicators.boop_interval_ms)
.unwrap_or(5_000);
let mut feedback = RecordingFeedback::new(RecordingFeedbackOptions {
no_sounds: opts.no_sounds,
no_boop: opts.no_boop,
no_overlay: opts.no_overlay,
viz: viz_mode,
mono: opts.mono,
boop_interval_ms,
capture_rate: audio_config.sample_rate,
pause_audio: false,
suppress_boop: None,
#[cfg(feature = "ui")]
overlay: RecordingOverlayOptions {
silence_tx: None,
auto_pause: true,
telemetry_rx: None,
},
});
let mut rx = start_capture_with_feedback(&mut feedback, &mut *capture).await?;
let mut writer = create_writer(&output_path, audio_config.clone())?;
let is_wav = matches!(
output_path.extension().and_then(|e| e.to_str()),
Some("wav")
);
if let Some(parent) = output_path.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent).await.map_err(|err| {
TalkError::Io(std::io::Error::new(
err.kind(),
format!(
"failed to create recording directory {}: {}",
parent.display(),
err
),
))
})?;
}
}
let mut file = tokio::fs::File::create(&output_path)
.await
.map_err(TalkError::Io)?;
let encode_task = tokio::spawn(async move {
let header = match writer.header() {
Ok(bytes) => bytes,
Err(err) => {
log::error!("error creating header: {}", err);
return Err(err);
}
};
if let Err(err) = file.write_all(&header).await {
log::error!("error writing header: {}", err);
return Err(TalkError::Io(err));
}
while let Some(pcm_chunk) = rx.recv().await {
let encoded_data = match writer.write_pcm(&pcm_chunk) {
Ok(data) => data,
Err(err) => {
log::error!("error encoding audio: {}", err);
return Err(err);
}
};
if !encoded_data.is_empty() {
if let Err(err) = file.write_all(&encoded_data).await {
log::error!("error writing to file: {}", err);
return Err(TalkError::Io(err));
}
}
}
match writer.finalize() {
Ok(remaining_data) => {
if !remaining_data.is_empty() {
if is_wav {
if let Err(err) = file.seek(SeekFrom::Start(0)).await {
log::error!("error seeking to start: {}", err);
return Err(TalkError::Io(err));
}
}
if let Err(err) = file.write_all(&remaining_data).await {
log::error!("error writing flushed data: {}", err);
return Err(TalkError::Io(err));
}
}
}
Err(err) => {
log::error!("error finalizing writer: {}", err);
return Err(err);
}
}
if let Err(err) = file.sync_all().await {
log::error!("error syncing file: {}", err);
return Err(TalkError::Io(err));
}
Ok::<(), TalkError>(())
});
if interrupt.recv().await.is_none() {
return Err(TalkError::Audio(
"SIGINT listener closed before recording stopped".to_string(),
));
}
println!("Stopping recording...");
stop_capture_with_feedback(&mut feedback, &mut *capture, || async move {
bt_guard.restore_now_async();
match encode_task.await {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(err),
Err(err) => Err(TalkError::Audio(format!("Encode task panicked: {}", err))),
}
})
.await?;
println!("Recording saved to: {}", output_path.display());
Ok(())
}
#[cfg(feature = "capture")]
async fn start_capture_with_feedback(
feedback: &mut RecordingFeedback,
capture: &mut dyn AudioCapture,
) -> Result<tokio::sync::mpsc::Receiver<Vec<i16>>, TalkError> {
feedback.play_start().await;
let raw_audio = capture.start()?;
feedback.begin_recording();
Ok(feedback.route_audio(raw_audio))
}
#[cfg(feature = "capture")]
async fn stop_capture_with_feedback<Finalize, FinalizeFuture>(
feedback: &mut RecordingFeedback,
capture: &mut dyn AudioCapture,
finalize: Finalize,
) -> Result<(), TalkError>
where
Finalize: FnOnce() -> FinalizeFuture,
FinalizeFuture: std::future::Future<Output = Result<(), TalkError>>,
{
feedback.teardown_recording(RecordingBadgeTeardown::Hide);
capture.stop()?;
finalize().await?;
feedback.play_stop().await;
Ok(())
}
#[cfg(feature = "capture")]
pub async fn record_daemon(opts: RecordOpts) -> Result<(), TalkError> {
let slot = crate::daemon::record_slot()?;
let _owner = slot.owner_guard();
record(opts).await
}
#[cfg(all(test, feature = "capture"))]
mod tests {
use super::*;
use chrono::Datelike;
use std::sync::{Arc, Mutex};
struct OrderedCapture {
events: Arc<Mutex<Vec<&'static str>>>,
}
impl AudioCapture for OrderedCapture {
fn start(&mut self) -> Result<tokio::sync::mpsc::Receiver<Vec<i16>>, TalkError> {
if let Ok(mut events) = self.events.lock() {
events.push("capture-start");
}
let (_tx, rx) = tokio::sync::mpsc::channel(1);
Ok(rx)
}
fn stop(&mut self) -> Result<(), TalkError> {
if let Ok(mut events) = self.events.lock() {
events.push("capture-stop");
}
Ok(())
}
}
#[tokio::test]
async fn record_feedback_wraps_capture_and_durable_finalization_in_order() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut feedback = crate::audio::recording_feedback::RecordingFeedback::new_for_test(
Arc::clone(&events),
true,
);
let mut capture = OrderedCapture {
events: Arc::clone(&events),
};
let _rx = start_capture_with_feedback(&mut feedback, &mut capture)
.await
.expect("start should succeed");
stop_capture_with_feedback(&mut feedback, &mut capture, || async {
if let Ok(mut events) = events.lock() {
events.push("encoder-finalize");
events.push("file-sync-all");
}
Ok(())
})
.await
.expect("stop should succeed");
assert_eq!(
events
.lock()
.map(|events| events.clone())
.unwrap_or_default(),
vec![
"start-tone",
"capture-start",
"badge-show",
"boop-start",
"boop-cancel",
"badge-hide",
"capture-stop",
"encoder-finalize",
"file-sync-all",
"stop-tone",
]
);
}
#[test]
fn test_resolve_output_path_no_args_nests_by_year_and_month() {
let output_dir = PathBuf::from("/tmp/test-output");
let args: Vec<String> = vec![];
let result = resolve_output_path(&args, &output_dir).expect("resolve should succeed");
let month_dir = result.parent().expect("should have month parent");
let year_dir = month_dir.parent().expect("should have year parent");
let root = year_dir.parent().expect("should have root parent");
assert_eq!(
root, output_dir,
"root above the YYYY/MM subdirs should be output_dir"
);
let year_name = year_dir
.file_name()
.expect("year dir name")
.to_string_lossy();
let month_name = month_dir
.file_name()
.expect("month dir name")
.to_string_lossy();
assert_eq!(year_name.len(), 4, "year segment should be 4 digits");
assert!(
year_name.chars().all(|c| c.is_ascii_digit()),
"year segment should be all digits, got: {}",
year_name
);
assert_eq!(month_name.len(), 2, "month segment should be 2 digits");
assert!(
month_name.chars().all(|c| c.is_ascii_digit()),
"month segment should be all digits, got: {}",
month_name
);
let filename = result
.file_name()
.expect("should have filename")
.to_string_lossy();
assert!(filename.ends_with(".ogg"), "filename should end with .ogg");
let stem = filename
.strip_suffix(".ogg")
.expect("filename should end with .ogg");
let parsed = chrono::DateTime::parse_from_str(stem, "%Y-%m-%dT%H-%M-%S%z")
.unwrap_or_else(|e| panic!("filename stem {} should parse as timestamp: {}", stem, e));
assert_eq!(
format!("{:04}", parsed.year()),
year_name,
"parsed year from filename {} should equal parent year dir {}",
filename,
year_name
);
assert_eq!(
format!("{:02}", parsed.month()),
month_name,
"parsed month from filename {} should equal parent month dir {}",
filename,
month_name
);
}
#[test]
fn test_resolve_output_path_with_filename() {
let output_dir = PathBuf::from("/tmp/test-output");
let args = vec!["my-recording.ogg".to_string()];
let result = resolve_output_path(&args, &output_dir).expect("resolve should succeed");
assert_eq!(result, PathBuf::from("my-recording.ogg"));
}
#[test]
fn test_resolve_output_path_with_absolute_path() {
let output_dir = PathBuf::from("/tmp/test-output");
let args = vec!["/tmp/my-recording.ogg".to_string()];
let result = resolve_output_path(&args, &output_dir).expect("resolve should succeed");
assert_eq!(result, PathBuf::from("/tmp/my-recording.ogg"));
}
#[test]
fn test_resolve_output_path_too_many_args() {
let output_dir = PathBuf::from("/tmp/test-output");
let args = vec!["file1.ogg".to_string(), "file2.ogg".to_string()];
let result = resolve_output_path(&args, &output_dir);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("at most one argument"));
}
#[tokio::test]
async fn test_record_pipeline_with_mock_capture() {
use crate::audio::{mock::MockAudioCapture, AudioCapture, AudioWriter, OggOpusWriter};
use crate::config::AudioConfig;
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("create temp dir");
let output_path = temp_dir.path().join("test-recording.ogg");
let audio_config = AudioConfig::new();
let mut capture =
MockAudioCapture::new(audio_config.sample_rate, audio_config.channels, 440.0);
let mut rx = capture.start().expect("start capture");
let mut writer = OggOpusWriter::new(audio_config).expect("create writer");
let mut file = tokio::fs::File::create(&output_path)
.await
.expect("create file");
let header = writer.header().expect("header");
file.write_all(&header).await.expect("write header");
for _ in 0..3 {
if let Some(pcm_chunk) = rx.recv().await {
let encoded_data = writer.write_pcm(&pcm_chunk).expect("encode");
if !encoded_data.is_empty() {
file.write_all(&encoded_data).await.expect("write to file");
}
}
}
let remaining_data = writer.finalize().expect("finalize");
if !remaining_data.is_empty() {
file.write_all(&remaining_data)
.await
.expect("write flushed data");
}
file.sync_all().await.expect("sync file");
capture.stop().expect("stop capture");
let metadata = fs::metadata(&output_path).expect("get file metadata");
assert!(metadata.len() > 0, "output file should have content");
let bytes = fs::read(&output_path).expect("read output file");
assert!(bytes.starts_with(b"OggS"), "output should start with OggS");
let has_opus_head = bytes
.windows(b"OpusHead".len())
.any(|window| window == b"OpusHead");
assert!(has_opus_head, "output should contain OpusHead");
}
}