Skip to content
Open
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,23 @@ 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)
2. Transcribe with WhisperX
3. Summarize with your local LLM
4. Save everything to `~/ownscribe/YYYY-MM-DD_HHMMSS/`

> **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 --no-mic # capture system audio only (mic is on by default; press 'm' to mute/unmute)
ownscribe --mic-device "MacBook Pro Microphone" # capture system audio + specific mic
ownscribe --device "MacBook Pro Microphone" # use mic instead of system audio
ownscribe --no-summarize # skip LLM summarization
Expand Down Expand Up @@ -213,9 +213,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]
Expand Down
15 changes: 11 additions & 4 deletions src/ownscribe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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
Comment on lines +101 to +104

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: I think --no-mic doesn't actually disable the mic when mic_device is set in config.toml:

  1. --no-mic sets config.audio.mic = False, but config.audio.mic_device keeps its value from the config file.
  2. CoreAudioRecorder.start() re-enables the mic from the device name alone (if self._mic or self._mic_device: cmd.append("--mic")).
  3. So the helper is still launched with --mic --mic-device "USB Mic" and we record the mic anyway.

Possible fix: clear config.audio.mic_device when --no-mic is given, e.g.

if mic is not None:
    config.audio.mic = mic
    if mic is False:
        config.audio.mic_device = ""

(same as CoPilot's comment below I just noticed)

if mic_device:
config.audio.mic = True
config.audio.mic_device = mic_device
Comment on lines +101 to 107
if keep_recording is not None:
config.output.keep_recording = keep_recording
Expand Down
8 changes: 4 additions & 4 deletions src/ownscribe/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -58,9 +58,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


Expand Down
13 changes: 10 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,20 @@ 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, ["--mic"])
result = runner.invoke(cli, ["--no-mic"])
assert result.exit_code == 0
config = mock_run.call_args[0][0]
assert config.audio.mic is True
assert config.audio.mic is False
Comment on lines +33 to +39
Comment on lines +33 to +39

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small things on the tests here, non-blocking:

  1. With test_mic_flag renamed, we no longer test that an explicit --mic overrides mic = false from the config file. Maybe keep a --mic test next to the --no-mic one?
  2. There's also no test that --mic-device alone implies mic = True — that implication now sits on its own line in cli.py and could regress silently.

Once the mic_device interaction above is fixed, a test for config-set mic_device + --no-mic would be a nice-to-have too.


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()
Expand Down
6 changes: 5 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,20 @@ 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

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
Expand Down
Loading