Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ mcp_bind_address = "127.0.0.1:2769"
Action semantics, shared by both transports:
- `start` begins recording only when `start_recording_enabled = true`; otherwise the request is rejected, and it is a no-op unless Muninn is idle.
- `stop` finishes the active recording and runs the transcription pipeline; no-op when idle.
- `toggle` starts when idle, otherwise stops the active recording.
- `toggle` starts when idle (subject to the same `start_recording_enabled = true` opt-in as `start`, since starting capture is gated regardless of verb), otherwise stops the active recording.
- `cancel` discards the active recording without running the pipeline or injecting text.

Recording does not stop on its own. An agent typically calls `start`, and a human ends it with the hotkey, a tray click, or an explicit `stop`/`cancel`. Opting in with `start_recording_enabled = true` is the local trust decision for configured agents and scripts; Muninn does not ask for a repeated interactive confirmation for every external start request after that opt-in.
Expand Down
29 changes: 29 additions & 0 deletions src/audio.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
//! macOS microphone capture via `cpal` and WAV output for the transcription pipeline.
//!
//! [`MacosAudioRecorder`] implements [`AudioRecorder`]: it opens the default input
//! device, buffers PCM in memory, optionally streams resampled frames to a live
//! transcription sink, and writes a temporary WAV on stop. Capture and file I/O are
//! macOS-only; other platforms return [`crate::MacosAdapterError::UnsupportedPlatform`].

use std::sync::Mutex;
use std::time::Instant;

Expand Down Expand Up @@ -27,6 +34,7 @@ const DEFAULT_STREAMING_FRAME_MS: u16 = 100;
#[cfg(target_os = "macos")]
use std::sync::Arc;

/// macOS `cpal` recorder that buffers input and writes a temp WAV on stop.
#[derive(Default)]
pub struct MacosAudioRecorder {
#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -86,6 +94,7 @@ struct CaptureConfigChoice {
}

impl MacosAudioRecorder {
/// Construct a recorder with the given output [`RecordingConfig`].
#[must_use]
pub const fn new(output_config: RecordingConfig) -> Self {
Self {
Expand All @@ -97,6 +106,7 @@ impl MacosAudioRecorder {
}
}

/// Replace output recording settings; drops a cached capture engine when idle.
pub fn set_recording_config(&mut self, output_config: RecordingConfig) {
let config_changed = self.output_config != output_config;
self.output_config = output_config;
Expand All @@ -106,13 +116,17 @@ impl MacosAudioRecorder {
}
}

/// Set the streaming transcription frame duration used when an audio sink is attached.
pub fn set_streaming_transcription_config(
&mut self,
streaming_config: StreamingTranscriptionConfig,
) {
self.streaming_frame_ms = streaming_config.frame_ms;
}

/// Open the default input device and build a paused capture engine.
///
/// macOS only. Useful to pay device-selection cost before the first recording.
pub async fn warm_up(&mut self) -> MacosAdapterResult<()> {
#[cfg(target_os = "macos")]
{
Expand All @@ -129,10 +143,19 @@ impl MacosAudioRecorder {

#[async_trait(?Send)]
impl AudioRecorder for MacosAudioRecorder {
/// Begin capture on the default macOS input device.
///
/// Delegates to [`AudioRecorder::start_recording_with_audio_sink`] without a
/// streaming sink.
async fn start_recording(&mut self) -> MacosAdapterResult<()> {
self.start_recording_with_audio_sink(None).await
}

/// Begin capture and optionally stream resampled [`AudioFrame`] batches to `sink`.
///
/// Rebuilds the `cpal` engine when idle and the default input device or
/// [`RecordingConfig`] changed since the last warm-up. Buffered capture is
/// capped at three minutes; overflow is flagged on stop.
async fn start_recording_with_audio_sink(
&mut self,
sink: Option<Sender<AudioFrame>>,
Expand Down Expand Up @@ -211,6 +234,11 @@ impl AudioRecorder for MacosAudioRecorder {
}
}

/// Pause capture, render a temp WAV, and return [`RecordedAudio`] metadata.
///
/// Flushes any partial streaming frame before clearing the sink. When the
/// in-memory buffer overflowed, reported duration reflects capped samples
/// rather than wall-clock elapsed time.
async fn stop_recording(&mut self) -> MacosAdapterResult<RecordedAudio> {
#[cfg(target_os = "macos")]
{
Expand Down Expand Up @@ -317,6 +345,7 @@ impl AudioRecorder for MacosAudioRecorder {
}
}

/// Discard the active capture without writing a WAV file.
async fn cancel_recording(&mut self) -> MacosAdapterResult<()> {
#[cfg(target_os = "macos")]
{
Expand Down
21 changes: 21 additions & 0 deletions src/audio/render.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
//! PCM resampling, channel downmixing, and WAV serialization for recorded audio.
//!
//! Source buffers from the `cpal` callback are normalized to 16-bit PCM, then
//! transformed to match [`RecordingConfig`] (mono downmix and target sample rate)
//! before writing the temp WAV or streaming [`AudioFrame`] batches.

use crate::config::RecordingConfig;
#[cfg(target_os = "macos")]
use crate::{MacosAdapterError, MacosAdapterResult};

/// Convert a normalized float sample to 16-bit PCM, clamping to `[-1.0, 1.0]`.
pub(super) fn normalized_f32_to_pcm_i16(sample: f32) -> i16 {
(sample.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16
}

/// Convert 16-bit PCM to a normalized float in `[-1.0, 1.0]`.
pub(super) fn pcm_i16_to_normalized_f32(sample: i16) -> f32 {
sample as f32 / i16::MAX as f32
}

/// Convert unsigned 16-bit PCM to signed 16-bit PCM.
pub(super) fn pcm_u16_to_pcm_i16(sample: u16) -> i16 {
normalized_f32_to_pcm_i16((sample as f32 / u16::MAX as f32) * 2.0 - 1.0)
}
Expand Down Expand Up @@ -121,11 +130,15 @@ impl Iterator for OutputSampleIter<'_> {
}
}

/// Output WAV header fields derived from source channels and [`RecordingConfig`].
pub(super) struct OutputWavSpec {
/// Sample rate in hertz for the rendered WAV.
pub(super) sample_rate: u32,
/// Channel count after optional mono downmix.
pub(super) channels: u16,
}

/// Derive WAV format metadata for the configured output recording settings.
pub(super) fn output_wav_spec(
source_channels: u16,
output_config: &RecordingConfig,
Expand All @@ -140,6 +153,7 @@ pub(super) fn output_wav_spec(
}
}

/// Resample, optionally downmix to mono, and encode as 16-bit PCM.
pub(super) fn render_output_pcm_i16(
samples: &[i16],
source_sample_rate: u32,
Expand All @@ -151,6 +165,10 @@ pub(super) fn render_output_pcm_i16(
.collect()
}

/// Stable checksum harness for render-path performance benchmarks.
///
/// Hidden from public rustdoc; returns rendered sample count and a wrapping sum
/// of quantized PCM values so benchmark runs can detect output drift.
#[doc(hidden)]
#[must_use]
pub fn benchmark_render_output_checksum(
Expand Down Expand Up @@ -182,6 +200,9 @@ pub(super) fn collect_output_samples(
OutputSampleIter::new(samples, source_sample_rate, source_channels, output_config).collect()
}

/// Write resampled PCM to a unique temp WAV file under the system temp directory.
///
/// macOS only; used when finalizing [`AudioRecorder::stop_recording`].
#[cfg(target_os = "macos")]
pub(super) fn write_wav_file(
samples: &[i16],
Expand Down
16 changes: 16 additions & 0 deletions src/autostart.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
//! macOS LaunchAgent synchronization for `app.autostart`.
//!
//! Writes `~/Library/LaunchAgents/com.bnomei.muninn.plist` with the canonical
//! executable path, `MUNINN_CONFIG`, and optional `.env` loading. Does not call
//! `launchctl`; login-session agents are picked up on next login.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};
Expand All @@ -10,15 +16,20 @@ const LAUNCH_AGENT_FILE_NAME: &str = "com.bnomei.muninn.plist";
const DEFAULT_LAUNCH_AGENT_PATH: &str =
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";

/// Result of synchronizing the user LaunchAgent with `app.autostart`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutostartSyncStatus {
/// LaunchAgent plist written or already matched desired state.
Enabled {
plist_path: PathBuf,
launch_path: PathBuf,
/// `true` when the plist content changed on disk.
changed: bool,
},
/// Autostart disabled; plist removed when present.
Disabled {
plist_path: PathBuf,
/// `true` when an existing plist file was deleted.
removed: bool,
},
}
Expand All @@ -31,6 +42,11 @@ struct LaunchAgentSpec {
load_dotenv: bool,
}

/// Write or remove the Muninn LaunchAgent plist to match `config.app.autostart`.
///
/// Canonicalizes the executable and config paths so relaunches survive symlinked
/// Homebrew installs. Sets `WorkingDirectory` to the config file parent so
/// relative `.env` loading behaves consistently at login.
pub fn sync_autostart(config_path: &Path, config: &AppConfig) -> Result<AutostartSyncStatus> {
let home_dir = std::env::var_os("HOME")
.map(PathBuf::from)
Expand Down
Loading
Loading