diff --git a/README.md b/README.md index 64e7bb8..97e0095 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ All audio, transcripts, and summaries remain local. ## Features - **System audio capture** — records all system audio natively via Core Audio Taps (macOS 14.2+), no virtual audio drivers needed -- **Microphone capture** — optionally record system + mic audio simultaneously with `--mic` +- **Microphone capture** — records system + mic audio simultaneously by default (press `m` to mute/unmute, or use `--no-mic`) - **WhisperX transcription** — fast, accurate speech-to-text with word-level timestamps - **Speaker diarization** — optional speaker identification via pyannote (requires HuggingFace token) - **Pipeline progress** — live checklist showing transcription, diarization sub-steps, and summarization progress @@ -70,7 +70,14 @@ Works with any app that outputs audio through Core Audio (Zoom, Teams, Meet, etc > ```bash > open "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" > ``` -> Enable your terminal app, then restart it. +> Enable your terminal app, then restart it. Both capture modes need this permission, `picker` and `all` alike. +> +> The microphone is recorded by default, so macOS also asks for **Microphone** permission on the first run. +> If you dismissed that prompt, enable your terminal app here: +> ```bash +> open "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" +> ``` +> Recording fails to start while the microphone is unavailable — use `--no-mic` to capture system audio only. ## Installation @@ -135,24 +142,24 @@ then call `ownscribe` directly. The examples in [Usage](#usage) use the bare ### Record, transcribe, and summarize a meeting ```bash -ownscribe # records system audio, Ctrl+C to stop +ownscribe # records system audio + mic, Ctrl+C to stop ``` This will: -1. Capture system audio until you press Ctrl+C (or auto-stop after 5 minutes of silence) +1. Capture system audio and your microphone until you press Ctrl+C (or auto-stop after 5 minutes of silence); press `m` to mute/unmute the mic while recording 2. Transcribe with WhisperX 3. Summarize with your local LLM -4. Save everything to `~/ownscribe/YYYY-MM-DD_HHMMSS/` +4. Save everything to `~/ownscribe/YYYY-MM-DD_HHMM/`, renamed to `~/ownscribe/YYYY-MM-DD_HHMM_meeting-title/` once the summary produces a title -> **Note:** By default, macOS shows a source picker on each launch so you can choose what to capture. To skip it and always record all system audio, set `capture_mode = "all"` in the `[audio]` config section. +> **Note:** By default, ownscribe records all system audio directly with no prompt. To show a macOS source picker on each launch instead, set `capture_mode = "picker"` in the `[audio]` config section. On first run, WhisperX / pyannote and the summarization model may download model files. ownscribe shows a `Preparing models` step and best-effort download progress in the TUI while this happens. Use `ownscribe warmup` to pre-download all models. ### Options ```bash -ownscribe --mic # capture system audio + default mic (press 'm' to mute/unmute) -ownscribe --mic-device "MacBook Pro Microphone" # capture system audio + specific mic +ownscribe --no-mic # capture system audio only (the mic is on by default) +ownscribe --mic-device "MacBook Pro Microphone" # capture system audio + a specific mic instead of the default one ownscribe --device "MacBook Pro Microphone" # use mic instead of system audio ownscribe --no-summarize # skip LLM summarization ownscribe --diarize # enable speaker identification @@ -213,9 +220,9 @@ Config is stored at `~/.config/ownscribe/config.toml`. Run `ownscribe config` to [audio] backend = "coreaudio" # "coreaudio" or "sounddevice" device = "" # empty = system audio -mic = false # also capture microphone input +mic = true # also capture microphone input mic_device = "" # specific mic device name (empty = default) -capture_mode = "picker" # "picker" = show source picker; "all" = capture all system audio directly +capture_mode = "all" # "all" = capture all system audio directly; "picker" = show source picker silence_timeout = 300 # seconds of silence before auto-stop; 0 = disabled [transcription] diff --git a/src/ownscribe/audio/coreaudio.py b/src/ownscribe/audio/coreaudio.py index 53f10b7..53e7e06 100644 --- a/src/ownscribe/audio/coreaudio.py +++ b/src/ownscribe/audio/coreaudio.py @@ -102,10 +102,12 @@ def start(self, output_path: Path) -> None: cmd = [str(self._binary), "capture", "--output", str(output_path)] if self._capture_mode == "all": cmd.append("--capture-mode-all") - if self._mic or self._mic_device: + # A configured mic_device must not re-enable a mic that was turned off, + # e.g. via --no-mic or mic = false in config.toml. + if self._mic: cmd.append("--mic") - if self._mic_device: - cmd.extend(["--mic-device", self._mic_device]) + if self._mic_device: + cmd.extend(["--mic-device", self._mic_device]) if self._silence_timeout > 0: cmd.extend(["--silence-timeout", str(self._silence_timeout)]) diff --git a/src/ownscribe/cli.py b/src/ownscribe/cli.py index 26c9c87..bbdbabc 100644 --- a/src/ownscribe/cli.py +++ b/src/ownscribe/cli.py @@ -39,7 +39,11 @@ def _dir_size(path: str) -> str: @click.option("--language", default=None, help="Language code for transcription (e.g. en, de, fr).") @click.option("--initial-prompt", default=None, help="Context text to prime Whisper (vocab, speaker names, etc.)") @click.option("--hotwords", default=None, help="Comma-separated words to boost Whisper recognition.") -@click.option("--mic", is_flag=True, help="Also capture microphone input (mixed with system audio).") +@click.option( + "--mic/--no-mic", + default=None, + help="Also capture microphone input (mixed with system audio); on by default.", +) @click.option("--mic-device", default=None, help="Specific mic device name (implies --mic).") @click.option( "--keep-recording/--no-keep-recording", @@ -62,7 +66,7 @@ def cli( language: str | None, initial_prompt: str | None, hotwords: str | None, - mic: bool, + mic: bool | None, mic_device: str | None, keep_recording: bool | None, template: str | None, @@ -94,9 +98,12 @@ def cli( config.transcription.initial_prompt = initial_prompt if hotwords: config.transcription.hotwords = hotwords - if mic or mic_device: - config.audio.mic = True + if mic is False and mic_device: + raise click.UsageError("--no-mic and --mic-device cannot be used together.") + if mic is not None: + config.audio.mic = mic if mic_device: + config.audio.mic = True config.audio.mic_device = mic_device if keep_recording is not None: config.output.keep_recording = keep_recording diff --git a/src/ownscribe/config.py b/src/ownscribe/config.py index d3ad222..29fb5ac 100644 --- a/src/ownscribe/config.py +++ b/src/ownscribe/config.py @@ -14,9 +14,9 @@ [audio] backend = "coreaudio" # "coreaudio" (default) or "sounddevice" device = "" # empty = system audio; or device name/index for sounddevice -mic = false # also capture microphone input +mic = true # also capture microphone input mic_device = "" # specific mic device name (empty = default) -capture_mode = "picker" # "picker" = show source picker; "all" = capture all system audio directly +capture_mode = "all" # "all" = capture all system audio directly; "picker" = show source picker silence_timeout = 300 # seconds of silence before auto-stop; 0 = disabled [transcription] @@ -59,9 +59,9 @@ class AudioConfig: backend: str = "coreaudio" device: str = "" - mic: bool = False + mic: bool = True mic_device: str = "" - capture_mode: str = "picker" # "picker" = show source picker; "all" = all system audio + capture_mode: str = "all" # "all" = all system audio; "picker" = show source picker silence_timeout: int = 300 # seconds of silence before auto-stop; 0 = disabled diff --git a/src/ownscribe/pipeline.py b/src/ownscribe/pipeline.py index 1fce786..59564a8 100644 --- a/src/ownscribe/pipeline.py +++ b/src/ownscribe/pipeline.py @@ -257,11 +257,15 @@ def on_interrupt(sig, frame): click.echo("\n\nStopping recording...") if not audio_path.exists() or audio_path.stat().st_size <= _WAV_HEADER_SIZE: - click.echo( + message = ( "Error: No audio was captured. Make sure audio is playing on your system, " - "or use --device to capture mic-only.", - err=True, + "or use --device to capture mic-only." ) + # The mic is captured by default, and the helper stops the whole recording + # when it cannot open the microphone (no input device, permission denied). + if config.audio.mic: + message += "\nIf the microphone is unavailable, use --no-mic to record system audio only." + click.echo(message, err=True) raise SystemExit(1) click.echo(f"Audio saved to {audio_path}\n") diff --git a/tests/test_cli.py b/tests/test_cli.py index 5ab1e40..17e4c8f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,13 +30,49 @@ def test_no_summarize_flag(self): config = mock_run.call_args[0][0] assert config.summarization.enabled is False - def test_mic_flag(self): + def test_no_mic_flag(self): runner = CliRunner() with _mock_config(), mock.patch("ownscribe.pipeline.run_pipeline") as mock_run: + result = runner.invoke(cli, ["--no-mic"]) + assert result.exit_code == 0 + config = mock_run.call_args[0][0] + assert config.audio.mic is False + + def test_mic_flag_overrides_config(self): + runner = CliRunner() + config = Config() + config.audio.mic = False + with _mock_config(config), mock.patch("ownscribe.pipeline.run_pipeline") as mock_run: result = runner.invoke(cli, ["--mic"]) assert result.exit_code == 0 + assert mock_run.call_args[0][0].audio.mic is True + + def test_mic_device_implies_mic(self): + runner = CliRunner() + config = Config() + config.audio.mic = False + with _mock_config(config), mock.patch("ownscribe.pipeline.run_pipeline") as mock_run: + result = runner.invoke(cli, ["--mic-device", "USB Mic"]) + assert result.exit_code == 0 config = mock_run.call_args[0][0] assert config.audio.mic is True + assert config.audio.mic_device == "USB Mic" + + def test_no_mic_wins_over_configured_mic_device(self): + runner = CliRunner() + config = Config() + config.audio.mic_device = "USB Mic" + with _mock_config(config), mock.patch("ownscribe.pipeline.run_pipeline") as mock_run: + result = runner.invoke(cli, ["--no-mic"]) + assert result.exit_code == 0 + assert mock_run.call_args[0][0].audio.mic is False + + def test_no_mic_with_mic_device_errors(self): + runner = CliRunner() + with _mock_config(): + result = runner.invoke(cli, ["--no-mic", "--mic-device", "USB Mic"]) + assert result.exit_code != 0 + assert "--no-mic and --mic-device cannot be used together" in result.output def test_device_flag(self): runner = CliRunner() diff --git a/tests/test_config.py b/tests/test_config.py index 1815dfa..99e9b5c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -29,9 +29,13 @@ def test_default_output_format(self): def test_default_mic_settings(self): cfg = Config() - assert cfg.audio.mic is False + assert cfg.audio.mic is True assert cfg.audio.mic_device == "" + def test_default_capture_mode(self): + cfg = Config() + assert cfg.audio.capture_mode == "all" + def test_default_diarization_telemetry_off(self): cfg = Config() assert cfg.diarization.telemetry is False diff --git a/tests/test_coreaudio.py b/tests/test_coreaudio.py new file mode 100644 index 0000000..819e312 --- /dev/null +++ b/tests/test_coreaudio.py @@ -0,0 +1,54 @@ +"""Tests for the Core Audio helper command line.""" + +from __future__ import annotations + +from pathlib import Path +from unittest import mock + + +def _make_recorder(**kwargs): + from ownscribe.audio.coreaudio import CoreAudioRecorder + + binary = Path("/usr/local/bin/ownscribe-audio") + with mock.patch("ownscribe.audio.coreaudio._find_binary", return_value=binary): + return CoreAudioRecorder(**kwargs) + + +def _capture_cmd(recorder, tmp_path: Path) -> list[str]: + with mock.patch("ownscribe.audio.coreaudio.subprocess.Popen") as mock_popen: + recorder.start(tmp_path / "recording.wav") + return mock_popen.call_args[0][0] + + +class TestCoreAudioRecorderCommand: + def test_mic_and_device_passed_when_mic_enabled(self, tmp_path): + cmd = _capture_cmd(_make_recorder(mic=True, mic_device="USB Mic"), tmp_path) + + assert "--mic" in cmd + assert cmd[cmd.index("--mic-device") + 1] == "USB Mic" + + def test_mic_device_ignored_when_mic_disabled(self, tmp_path): + cmd = _capture_cmd(_make_recorder(mic=False, mic_device="USB Mic"), tmp_path) + + assert "--mic" not in cmd + assert "--mic-device" not in cmd + + def test_capture_mode_all(self, tmp_path): + cmd = _capture_cmd(_make_recorder(capture_mode="all"), tmp_path) + + assert "--capture-mode-all" in cmd + + def test_capture_mode_picker(self, tmp_path): + cmd = _capture_cmd(_make_recorder(capture_mode="picker"), tmp_path) + + assert "--capture-mode-all" not in cmd + + def test_silence_timeout_passed(self, tmp_path): + cmd = _capture_cmd(_make_recorder(silence_timeout=120), tmp_path) + + assert cmd[cmd.index("--silence-timeout") + 1] == "120" + + def test_silence_timeout_omitted_when_disabled(self, tmp_path): + cmd = _capture_cmd(_make_recorder(silence_timeout=0), tmp_path) + + assert "--silence-timeout" not in cmd diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 0dab2d8..b6d0973 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -60,7 +60,7 @@ def test_silence_timeout_passed_to_coreaudio(self): with mock.patch("ownscribe.audio.coreaudio.CoreAudioRecorder") as mock_cls: mock_cls.return_value.is_available.return_value = True _create_recorder(config) - mock_cls.assert_called_once_with(mic=False, mic_device="", capture_mode="picker", silence_timeout=120) + mock_cls.assert_called_once_with(mic=True, mic_device="", capture_mode="all", silence_timeout=120) def test_capture_mode_passed_to_coreaudio(self): from ownscribe.pipeline import _create_recorder @@ -68,12 +68,12 @@ def test_capture_mode_passed_to_coreaudio(self): config = Config() config.audio.backend = "coreaudio" config.audio.device = "" - config.audio.capture_mode = "all" + config.audio.capture_mode = "picker" with mock.patch("ownscribe.audio.coreaudio.CoreAudioRecorder") as mock_cls: mock_cls.return_value.is_available.return_value = True _create_recorder(config) - mock_cls.assert_called_once_with(mic=False, mic_device="", capture_mode="all", silence_timeout=300) + mock_cls.assert_called_once_with(mic=True, mic_device="", capture_mode="picker", silence_timeout=300) def test_silence_timeout_passed_to_sounddevice(self): from ownscribe.pipeline import _create_recorder @@ -557,6 +557,41 @@ def test_audio_recorded_into_separate_audio_dir(self, tmp_path): assert called_out_dir.is_relative_to(tmp_path / "notes") assert called_out_dir.name == audio_path.parent.name + def test_no_audio_captured_hints_at_no_mic(self, tmp_path, capsys): + from ownscribe.pipeline import run_pipeline + + config = Config() + config.output.dir = str(tmp_path / "notes") + + recorder = self._make_recorder_mock() + recorder.start.side_effect = lambda path: path.write_bytes(b"") + + with ( + mock.patch("ownscribe.pipeline._create_recorder", return_value=recorder), + contextlib.suppress(SystemExit), + ): + run_pipeline(config) + + assert "--no-mic" in capsys.readouterr().err + + def test_no_audio_captured_without_mic_omits_hint(self, tmp_path, capsys): + from ownscribe.pipeline import run_pipeline + + config = Config() + config.output.dir = str(tmp_path / "notes") + config.audio.mic = False + + recorder = self._make_recorder_mock() + recorder.start.side_effect = lambda path: path.write_bytes(b"") + + with ( + mock.patch("ownscribe.pipeline._create_recorder", return_value=recorder), + contextlib.suppress(SystemExit), + ): + run_pipeline(config) + + assert "--no-mic" not in capsys.readouterr().err + def test_audio_recorded_into_dir_when_audio_dir_unset(self, tmp_path): from ownscribe.pipeline import run_pipeline