From b98b226b35b57f2bb9f2b2b538baaa0a256c26a6 Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:01:44 +0900 Subject: [PATCH 01/11] fix error handling, remove dead code, modernize typing --- hns/cli.py | 85 ++++++++++++++++++++++-------------------------------- 1 file changed, 35 insertions(+), 50 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index 6bfc618..9850619 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -4,7 +4,6 @@ import time import wave from pathlib import Path -from typing import Optional, Union import click import numpy as np @@ -28,15 +27,30 @@ def format_duration(seconds: float) -> str: return f"{minutes:02d}:{secs:02d}" +def get_audio_file_path() -> Path: + cache_dir_override = os.environ.get("HNS_CACHE_DIR") + if cache_dir_override: + cache_dir = Path(cache_dir_override).expanduser() + elif sys.platform == "win32": + cache_dir = Path.home() / "AppData" / "Local" / "hns" / "Cache" + elif sys.platform == "darwin": + cache_dir = Path.home() / "Library" / "Caches" / "hns" + else: + cache_dir = Path.home() / ".cache" / "hns" + + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / "last_recording.wav" + + class AudioRecorder: def __init__(self, sample_rate: int = 16000, channels: int = 1): self.sample_rate = sample_rate self.channels = channels - self.audio_file_path = self._get_audio_file_path() + self.audio_file_path = get_audio_file_path() self.wave_file = None self.recording_frames = 0 - def _audio_callback(self, indata, frames, time, status): + def _audio_callback(self, indata, frames, time_info, status): if status: console.print(f"⚠️ [bold yellow]Audio warning: {status}[/bold yellow]") if self.wave_file: @@ -56,7 +70,7 @@ def record(self) -> Path: ) except Exception as e: self._close_wave_file() - raise RuntimeError(f"Failed to initialize audio stream: {e}") + raise RuntimeError(f"Failed to initialize audio stream: {e}") from e # Setup timer for live recording display start_time = time.time() @@ -109,25 +123,9 @@ def _validate_audio_device(self): try: import sounddevice as sd - default_input = sd.query_devices(kind="input") - if default_input is None: - raise RuntimeError("No audio input device found") + sd.query_devices(kind="input") except Exception as e: - raise RuntimeError(f"Failed to access audio devices: {e}") - - def _get_audio_file_path(self) -> Path: - cache_dir_override = os.environ.get("HNS_CACHE_DIR") - if cache_dir_override: - cache_dir = Path(cache_dir_override).expanduser() - elif sys.platform == "win32": - cache_dir = Path.home() / "AppData" / "Local" / "hns" / "Cache" - elif sys.platform == "darwin": - cache_dir = Path.home() / "Library" / "Caches" / "hns" - else: - cache_dir = Path.home() / ".cache" / "hns" - - cache_dir.mkdir(parents=True, exist_ok=True) - return cache_dir / "last_recording.wav" + raise RuntimeError(f"No audio input device available: {e}") from e def _prepare_wave_file(self): self.recording_frames = 0 @@ -143,7 +141,7 @@ def _close_wave_file(self): class WhisperTranscriber: - VALID_MODELS = [ + VALID_MODELS = ( "tiny.en", "tiny", "base.en", @@ -163,25 +161,14 @@ class WhisperTranscriber: "distil-large-v3.5", "large-v3-turbo", "turbo", - ] + ) - def __init__(self, model_name: Optional[str] = None, language: Optional[str] = None): + def __init__(self, model_name: str | None = None, language: str | None = None): self.model_name = self._get_model_name(model_name) self.language = language or os.environ.get("HNS_LANG") self.model = self._load_model() - def _get_audio_duration(self, audio_file_path: Union[Path, str]) -> Optional[float]: - """Get duration of audio file in seconds.""" - try: - with wave.open(str(audio_file_path), "rb") as audio_file: - frames = audio_file.getnframes() - sample_rate = audio_file.getframerate() - duration = frames / float(sample_rate) - return duration - except Exception: - return None - - def _get_model_name(self, model_name: Optional[str]) -> str: + def _get_model_name(self, model_name: str | None) -> str: model = model_name or os.environ.get("HNS_WHISPER_MODEL", "base") if model not in self.VALID_MODELS: @@ -197,9 +184,9 @@ def _load_model(self): try: return WhisperModel(self.model_name, device="cpu", compute_type="int8") except Exception as e: - raise RuntimeError(f"Failed to load model: {e}") + raise RuntimeError(f"Failed to load model '{self.model_name}': {e}") from e - def transcribe(self, audio_source: Union[Path, str], show_progress: bool = True) -> str: + def transcribe(self, audio_source: Path | str, show_progress: bool = True) -> str: transcribe_kwargs = { "beam_size": 5, "vad_filter": True, @@ -260,15 +247,14 @@ def transcribe_worker(): text = segment.text.strip() if text: transcription_parts.append(text) + except Exception as e: + raise RuntimeError(f"Transcription failed: {e}") from e - full_transcription = " ".join(transcription_parts) - if not full_transcription: - raise ValueError("No speech detected in audio") + full_transcription = " ".join(transcription_parts) + if not full_transcription: + raise ValueError("No speech detected in audio") - elapsed_total = time.time() - start_time - return full_transcription, elapsed_total if show_progress else None - except Exception as e: - raise RuntimeError(f"Transcription failed: {e}") + return full_transcription @classmethod def list_models(cls): @@ -292,7 +278,7 @@ def copy_to_clipboard(text: str): @click.option("--list-models", is_flag=True, help="List available Whisper models and exit") @click.option("--language", help="Force language detection (e.g., en, es, fr). Can also use HNS_LANG env var") @click.option("--last", is_flag=True, help="Transcribe the last recorded audio file") -def main(sample_rate: int, channels: int, list_models: bool, language: Optional[str], last: bool): +def main(sample_rate: int, channels: int, list_models: bool, language: str | None, last: bool): """Record audio from microphone, transcribe it, and copy to clipboard.""" if list_models: @@ -301,8 +287,7 @@ def main(sample_rate: int, channels: int, list_models: bool, language: Optional[ try: if last: - recorder = AudioRecorder(sample_rate, channels) - audio_file_path = recorder._get_audio_file_path() + audio_file_path = get_audio_file_path() if not audio_file_path.exists(): console.print( "❌ [bold red]No previous recording found. Record audio first by running 'hns' without --last flag.[/bold red]" @@ -312,7 +297,7 @@ def main(sample_rate: int, channels: int, list_models: bool, language: Optional[ recorder = AudioRecorder(sample_rate, channels) audio_file_path = recorder.record() transcriber = WhisperTranscriber(language=language) - transcription, _ = transcriber.transcribe(audio_file_path, show_progress=True) + transcription = transcriber.transcribe(audio_file_path, show_progress=True) try: copy_to_clipboard(transcription) From b5305d46815e477a3d441daae664e5683e855ddf Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:02:55 +0900 Subject: [PATCH 02/11] replace manual threading with rich Live timers --- hns/cli.py | 109 +++++++++++------------------------------------------ 1 file changed, 23 insertions(+), 86 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index 9850619..b511182 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -1,6 +1,5 @@ import os import sys -import threading import time import wave from pathlib import Path @@ -9,6 +8,8 @@ import numpy as np import pyperclip from rich.console import Console +from rich.live import Live +from rich.text import Text console = Console(stderr=True) stdout_console = Console() @@ -27,6 +28,19 @@ def format_duration(seconds: float) -> str: return f"{minutes:02d}:{secs:02d}" +class TimerLine: + def __init__(self, template: str): + self.template = template + self.start_time = time.time() + + @property + def elapsed(self) -> float: + return time.time() - self.start_time + + def __rich__(self) -> Text: + return Text.from_markup(self.template.format(elapsed=format_duration(self.elapsed))) + + def get_audio_file_path() -> Path: cache_dir_override = os.environ.get("HNS_CACHE_DIR") if cache_dir_override: @@ -72,46 +86,15 @@ def record(self) -> Path: self._close_wave_file() raise RuntimeError(f"Failed to initialize audio stream: {e}") from e - # Setup timer for live recording display - start_time = time.time() - recording_stopped = threading.Event() - + timer = TimerLine("🎤 [bold blue]Recording ...... {elapsed} Press Enter to stop[/bold blue]") try: - with stream: - - def update_timer(): - """Update the display with elapsed time.""" - while not recording_stopped.is_set(): - elapsed = time.time() - start_time - time_str = format_duration(elapsed) - # Overwrite the same line - console.print( - f"🎤 [bold blue]Recording ...... {time_str} Press Enter to stop[/bold blue]", end="\r" - ) - time.sleep(1) - - # Start timer thread - timer_thread = threading.Thread(target=update_timer) - timer_thread.daemon = True - timer_thread.start() - - # Wait for user input + with stream, Live(timer, console=console, refresh_per_second=4, transient=True): input() - - # Stop timer and wait for thread to finish - recording_stopped.set() - timer_thread.join(timeout=2) # Wait up to 2 seconds for thread to finish - - # Clear the recording line completely - console.print(" " * 50, end="\r") # Clear line with spaces - except KeyboardInterrupt: - recording_stopped.set() - console.print("\n⏹️ [bold yellow]Recording cancelled[/bold yellow]") + console.print("⏹️ [bold yellow]Recording cancelled[/bold yellow]") self._close_wave_file() sys.exit(0) finally: - recording_stopped.set() self._close_wave_file() if self.recording_frames == 0: @@ -186,7 +169,7 @@ def _load_model(self): except Exception as e: raise RuntimeError(f"Failed to load model '{self.model_name}': {e}") from e - def transcribe(self, audio_source: Path | str, show_progress: bool = True) -> str: + def transcribe(self, audio_source: Path | str) -> str: transcribe_kwargs = { "beam_size": 5, "vad_filter": True, @@ -196,57 +179,11 @@ def transcribe(self, audio_source: Path | str, show_progress: bool = True) -> st if self.language: transcribe_kwargs["language"] = self.language + timer = TimerLine("🔄 [bold blue]Transcribing ... {elapsed}[/bold blue]") try: - start_time = time.time() - - if show_progress: - import queue - import threading - - progress_queue = queue.Queue() - transcription_complete = threading.Event() - - def transcribe_worker(): - """Worker function to perform transcription in background.""" - try: - segments, _ = self.model.transcribe(str(audio_source), **transcribe_kwargs) - transcription_parts = [] - for segment in segments: - text = segment.text.strip() - if text: - transcription_parts.append(text) - progress_queue.put(("result", transcription_parts)) - except Exception as e: - progress_queue.put(("error", e)) - finally: - transcription_complete.set() - - # Start transcription in background - worker_thread = threading.Thread(target=transcribe_worker) - worker_thread.daemon = True - worker_thread.start() - - # Simple progress display with elapsed timer - while not transcription_complete.is_set(): - elapsed = time.time() - start_time - time_str = format_duration(elapsed) - console.print(f"🔄 [bold blue]Transcribing ... {time_str}[/bold blue]", end="\r") - time.sleep(1) - - # Print a new line - console.print("") - - result_type, result_data = progress_queue.get() - if result_type == "error": - raise result_data - transcription_parts = result_data - else: + with Live(timer, console=console, refresh_per_second=4, transient=True): segments, _ = self.model.transcribe(str(audio_source), **transcribe_kwargs) - transcription_parts = [] - for segment in segments: - text = segment.text.strip() - if text: - transcription_parts.append(text) + transcription_parts = [text for segment in segments if (text := segment.text.strip())] except Exception as e: raise RuntimeError(f"Transcription failed: {e}") from e @@ -297,7 +234,7 @@ def main(sample_rate: int, channels: int, list_models: bool, language: str | Non recorder = AudioRecorder(sample_rate, channels) audio_file_path = recorder.record() transcriber = WhisperTranscriber(language=language) - transcription = transcriber.transcribe(audio_file_path, show_progress=True) + transcription = transcriber.transcribe(audio_file_path) try: copy_to_clipboard(transcription) From 9e20e7e9c9f78e4bb074782870109a4c439f261f Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:03:26 +0900 Subject: [PATCH 03/11] keep last recording safe on cancel, handle ctrl-c and non-tty stdin --- hns/cli.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index b511182..852d864 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -73,8 +73,14 @@ def _audio_callback(self, indata, frames, time_info, status): self.recording_frames += frames def record(self) -> Path: + if not sys.stdin.isatty(): + raise RuntimeError( + "Recording needs an interactive terminal. Use --last to retranscribe the previous recording." + ) + self._validate_audio_device() - self._prepare_wave_file() + temp_path = self.audio_file_path.with_name(self.audio_file_path.name + ".tmp") + self._prepare_wave_file(temp_path) try: import sounddevice as sd @@ -84,6 +90,7 @@ def record(self) -> Path: ) except Exception as e: self._close_wave_file() + temp_path.unlink(missing_ok=True) raise RuntimeError(f"Failed to initialize audio stream: {e}") from e timer = TimerLine("🎤 [bold blue]Recording ...... {elapsed} Press Enter to stop[/bold blue]") @@ -91,15 +98,17 @@ def record(self) -> Path: with stream, Live(timer, console=console, refresh_per_second=4, transient=True): input() except KeyboardInterrupt: - console.print("⏹️ [bold yellow]Recording cancelled[/bold yellow]") self._close_wave_file() - sys.exit(0) + temp_path.unlink(missing_ok=True) + raise finally: self._close_wave_file() if self.recording_frames == 0: + temp_path.unlink(missing_ok=True) raise ValueError("No audio recorded") + temp_path.replace(self.audio_file_path) return self.audio_file_path def _validate_audio_device(self): @@ -110,9 +119,9 @@ def _validate_audio_device(self): except Exception as e: raise RuntimeError(f"No audio input device available: {e}") from e - def _prepare_wave_file(self): + def _prepare_wave_file(self, path: Path): self.recording_frames = 0 - self.wave_file = wave.open(str(self.audio_file_path), "wb") + self.wave_file = wave.open(str(path), "wb") self.wave_file.setnchannels(self.channels) self.wave_file.setsampwidth(2) # 16-bit audio self.wave_file.setframerate(self.sample_rate) @@ -243,6 +252,9 @@ def main(sample_rate: int, channels: int, list_models: bool, language: str | Non stdout_console.print(transcription) + except KeyboardInterrupt: + console.print("⏹️ [bold yellow]Cancelled[/bold yellow]") + sys.exit(130) except (RuntimeError, ValueError) as e: console.print(f"❌ [bold red]{e}[/bold red]") sys.exit(1) From 05b40a0a7ec1acdd5297cedcb362e1c413ac5b39 Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:03:58 +0900 Subject: [PATCH 04/11] add --model, --version, --no-copy; drop --sample-rate and --channels --- hns/cli.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index 852d864..a3244dc 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -219,12 +219,18 @@ def copy_to_clipboard(text: str): @click.command() -@click.option("--sample-rate", default=16000, help="Sample rate for audio recording") -@click.option("--channels", default=1, help="Number of audio channels") -@click.option("--list-models", is_flag=True, help="List available Whisper models and exit") +@click.version_option(package_name="hns", prog_name="hns") +@click.option( + "--model", + "-m", + type=click.Choice(WhisperTranscriber.VALID_MODELS), + help="Whisper model to use. Defaults to HNS_WHISPER_MODEL env var or 'base'", +) @click.option("--language", help="Force language detection (e.g., en, es, fr). Can also use HNS_LANG env var") @click.option("--last", is_flag=True, help="Transcribe the last recorded audio file") -def main(sample_rate: int, channels: int, list_models: bool, language: str | None, last: bool): +@click.option("--no-copy", is_flag=True, help="Do not copy the transcription to the clipboard") +@click.option("--list-models", is_flag=True, help="List available Whisper models and exit") +def main(model: str | None, language: str | None, last: bool, no_copy: bool, list_models: bool): """Record audio from microphone, transcribe it, and copy to clipboard.""" if list_models: @@ -240,15 +246,15 @@ def main(sample_rate: int, channels: int, list_models: bool, language: str | Non ) sys.exit(1) else: - recorder = AudioRecorder(sample_rate, channels) - audio_file_path = recorder.record() - transcriber = WhisperTranscriber(language=language) + audio_file_path = AudioRecorder().record() + transcriber = WhisperTranscriber(model_name=model, language=language) transcription = transcriber.transcribe(audio_file_path) - try: - copy_to_clipboard(transcription) - except Exception as e: - console.print(f"⚠️ [bold yellow]Failed to copy to clipboard: {e}[/bold yellow]") + if not no_copy: + try: + copy_to_clipboard(transcription) + except Exception as e: + console.print(f"⚠️ [bold yellow]Failed to copy to clipboard: {e}[/bold yellow]") stdout_console.print(transcription) From 218d3d926010cd5ecec653aa3fbb608ef2d53a39 Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:04:44 +0900 Subject: [PATCH 05/11] improve status messages, clipboard hints, and --list-models output --- hns/cli.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index a3244dc..5aa6b12 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -109,6 +109,7 @@ def record(self) -> Path: raise ValueError("No audio recorded") temp_path.replace(self.audio_file_path) + console.print(f"⏹️ [bold blue]Recorded {format_duration(timer.elapsed)}[/bold blue]") return self.audio_file_path def _validate_audio_device(self): @@ -173,6 +174,7 @@ def _get_model_name(self, model_name: str | None) -> str: def _load_model(self): from faster_whisper import WhisperModel + console.print(f"⏳ [dim]Loading model '{self.model_name}' (first use downloads it)[/dim]") try: return WhisperModel(self.model_name, device="cpu", compute_type="int8") except Exception as e: @@ -200,13 +202,14 @@ def transcribe(self, audio_source: Path | str) -> str: if not full_transcription: raise ValueError("No speech detected in audio") + console.print(f"✅ [bold green]Transcribed in {format_duration(timer.elapsed)}[/bold green]") return full_transcription @classmethod def list_models(cls): console.print("ℹ️ [bold cyan]Available Whisper models:[/bold cyan]") for model in cls.VALID_MODELS: - console.print(f" • [dim]{model}[/dim]") + stdout_console.print(model) console.print("\nℹ️ [bold cyan]Environment variables:[/bold cyan]") console.print(" [dim]export HNS_WHISPER_MODEL=[/dim]") console.print(" [dim]export HNS_LANG= # e.g., en, es, fr[/dim]") @@ -214,7 +217,13 @@ def list_models(cls): def copy_to_clipboard(text: str): - pyperclip.copy(text) + try: + pyperclip.copy(text) + except Exception as e: + console.print(f"⚠️ [bold yellow]Failed to copy to clipboard: {e}[/bold yellow]") + if sys.platform.startswith("linux"): + console.print(" [dim]Install xclip, xsel, or wl-clipboard to enable clipboard support[/dim]") + return console.print("✅ [bold green]Copied to clipboard![/bold green]") @@ -251,10 +260,7 @@ def main(model: str | None, language: str | None, last: bool, no_copy: bool, lis transcription = transcriber.transcribe(audio_file_path) if not no_copy: - try: - copy_to_clipboard(transcription) - except Exception as e: - console.print(f"⚠️ [bold yellow]Failed to copy to clipboard: {e}[/bold yellow]") + copy_to_clipboard(transcription) stdout_console.print(transcription) From eb107ad1863660d5d41d3bdffcc6b0bd04516efd Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:05:31 +0900 Subject: [PATCH 06/11] update agent guide for new CLI surface --- AGENTS.md | 8 +++++++- hns/cli.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afa7d54..bd9d7ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,14 +44,20 @@ There is no full automated test suite. For behavior changes, run `make test-help - `HNS_WHISPER_MODEL`: default Whisper model when no explicit model is passed internally; invalid values fall back to `base`. - `HNS_LANG`: optional language code passed to Whisper, e.g. `en`, `es`, `fr`. - `HNS_CACHE_DIR`: optional cache directory override for `last_recording.wav`, mainly useful for isolated fixture tests. +- `--model`/`-m`: CLI override for the Whisper model; takes precedence over `HNS_WHISPER_MODEL`. - `--language`: CLI override for transcription language. - `--last`: retranscribes the cached last recording instead of recording new audio. +- `--no-copy`: skips copying the transcription to the clipboard. +- `--list-models`: prints model names to stdout; usage hints go to stderr. +- `--version`: prints the installed version. Cached audio is written to the platform cache directory as `last_recording.wav`. ## Code Organization - `format_duration`: shared timer formatting. +- `TimerLine`: elapsed-time renderable for `rich` `Live` status displays. +- `get_audio_file_path`: resolves the cross-platform cache path for `last_recording.wav`. - `AudioRecorder`: validates input device, records microphone audio, writes WAV data, manages cache path. - `WhisperTranscriber`: validates model names, loads `faster-whisper`, transcribes audio, lists models. - `copy_to_clipboard`: copies final text and reports clipboard status. @@ -79,5 +85,5 @@ Cached audio is written to the platform cache directory as `last_recording.wav`. - Recording/transcription paths are interactive and hardware-dependent; avoid breaking non-audio commands like `--help` and `--list-models`. - First transcription may download a Whisper model; do not add network requirements beyond model download behavior. - Clipboard failures should not prevent printing transcription to stdout. -- Cross-platform cache paths in `AudioRecorder._get_audio_file_path` affect `--last` behavior. +- Cross-platform cache paths in `get_audio_file_path` affect `--last` behavior. - Published package behavior comes from the `hns = "hns.cli:main"` console script in `pyproject.toml`. diff --git a/hns/cli.py b/hns/cli.py index 5aa6b12..fd9d375 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -124,7 +124,7 @@ def _prepare_wave_file(self, path: Path): self.recording_frames = 0 self.wave_file = wave.open(str(path), "wb") self.wave_file.setnchannels(self.channels) - self.wave_file.setsampwidth(2) # 16-bit audio + self.wave_file.setsampwidth(2) self.wave_file.setframerate(self.sample_rate) def _close_wave_file(self): From 03330ffc84fd9f3b611793f0464c5aa982ce7ba1 Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:30:27 +0900 Subject: [PATCH 07/11] write transcription and model list to stdout unwrapped --- hns/cli.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index fd9d375..c2f847a 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -12,7 +12,6 @@ from rich.text import Text console = Console(stderr=True) -stdout_console = Console() def format_duration(seconds: float) -> str: @@ -209,7 +208,7 @@ def transcribe(self, audio_source: Path | str) -> str: def list_models(cls): console.print("ℹ️ [bold cyan]Available Whisper models:[/bold cyan]") for model in cls.VALID_MODELS: - stdout_console.print(model) + click.echo(model) console.print("\nℹ️ [bold cyan]Environment variables:[/bold cyan]") console.print(" [dim]export HNS_WHISPER_MODEL=[/dim]") console.print(" [dim]export HNS_LANG= # e.g., en, es, fr[/dim]") @@ -262,7 +261,7 @@ def main(model: str | None, language: str | None, last: bool, no_copy: bool, lis if not no_copy: copy_to_clipboard(transcription) - stdout_console.print(transcription) + click.echo(transcription) except KeyboardInterrupt: console.print("⏹️ [bold yellow]Cancelled[/bold yellow]") From d784ce8a5d365a3aaf02d99734a0a5935e14fd92 Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:31:54 +0900 Subject: [PATCH 08/11] replace emoji status with glyph vocabulary and single summary line --- hns/cli.py | 100 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 60 insertions(+), 40 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index c2f847a..1dfe5ca 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -9,9 +9,11 @@ import pyperclip from rich.console import Console from rich.live import Live +from rich.markup import escape from rich.text import Text console = Console(stderr=True) +SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" def format_duration(seconds: float) -> str: @@ -37,7 +39,12 @@ def elapsed(self) -> float: return time.time() - self.start_time def __rich__(self) -> Text: - return Text.from_markup(self.template.format(elapsed=format_duration(self.elapsed))) + spinner = SPINNER_FRAMES[int(self.elapsed * 10) % len(SPINNER_FRAMES)] + return Text.from_markup(self.template.format(elapsed=format_duration(self.elapsed), spinner=spinner)) + + +def live_line(line: TimerLine) -> Live: + return Live(line, console=console, refresh_per_second=10, transient=True) def get_audio_file_path() -> Path: @@ -63,9 +70,13 @@ def __init__(self, sample_rate: int = 16000, channels: int = 1): self.wave_file = None self.recording_frames = 0 + @property + def duration(self) -> float: + return self.recording_frames / self.sample_rate + def _audio_callback(self, indata, frames, time_info, status): if status: - console.print(f"⚠️ [bold yellow]Audio warning: {status}[/bold yellow]") + console.print(f"[yellow]![/yellow] audio warning: {escape(str(status))}") if self.wave_file: audio_int16 = (indata * 32767).astype(np.int16) self.wave_file.writeframes(audio_int16.tobytes()) @@ -74,7 +85,7 @@ def _audio_callback(self, indata, frames, time_info, status): def record(self) -> Path: if not sys.stdin.isatty(): raise RuntimeError( - "Recording needs an interactive terminal. Use --last to retranscribe the previous recording." + "recording needs an interactive terminal; use --last to retranscribe the previous recording" ) self._validate_audio_device() @@ -90,11 +101,11 @@ def record(self) -> Path: except Exception as e: self._close_wave_file() temp_path.unlink(missing_ok=True) - raise RuntimeError(f"Failed to initialize audio stream: {e}") from e + raise RuntimeError(f"failed to initialize audio stream: {e}") from e - timer = TimerLine("🎤 [bold blue]Recording ...... {elapsed} Press Enter to stop[/bold blue]") + timer = TimerLine("[red]●[/red] recording {elapsed} [dim]· press enter to stop[/dim]") try: - with stream, Live(timer, console=console, refresh_per_second=4, transient=True): + with stream, live_line(timer): input() except KeyboardInterrupt: self._close_wave_file() @@ -105,10 +116,9 @@ def record(self) -> Path: if self.recording_frames == 0: temp_path.unlink(missing_ok=True) - raise ValueError("No audio recorded") + raise ValueError("no audio recorded") temp_path.replace(self.audio_file_path) - console.print(f"⏹️ [bold blue]Recorded {format_duration(timer.elapsed)}[/bold blue]") return self.audio_file_path def _validate_audio_device(self): @@ -117,7 +127,7 @@ def _validate_audio_device(self): sd.query_devices(kind="input") except Exception as e: - raise RuntimeError(f"No audio input device available: {e}") from e + raise RuntimeError(f"no audio input device available: {e}") from e def _prepare_wave_file(self, path: Path): self.recording_frames = 0 @@ -164,8 +174,8 @@ def _get_model_name(self, model_name: str | None) -> str: model = model_name or os.environ.get("HNS_WHISPER_MODEL", "base") if model not in self.VALID_MODELS: - console.print(f"⚠️ [bold yellow]Invalid model '{model}', using 'base' instead[/bold yellow]") - console.print(f" [dim]Available models: {', '.join(self.VALID_MODELS)}[/dim]") + console.print(f"[yellow]![/yellow] invalid model '{escape(model)}', using 'base'") + console.print(" [dim]run hns --list-models to see available models[/dim]") return "base" return model @@ -173,11 +183,12 @@ def _get_model_name(self, model_name: str | None) -> str: def _load_model(self): from faster_whisper import WhisperModel - console.print(f"⏳ [dim]Loading model '{self.model_name}' (first use downloads it)[/dim]") + timer = TimerLine(f"[cyan]{{spinner}}[/cyan] loading {self.model_name} model") try: - return WhisperModel(self.model_name, device="cpu", compute_type="int8") + with live_line(timer): + return WhisperModel(self.model_name, device="cpu", compute_type="int8") except Exception as e: - raise RuntimeError(f"Failed to load model '{self.model_name}': {e}") from e + raise RuntimeError(f"failed to load model '{self.model_name}': {e}") from e def transcribe(self, audio_source: Path | str) -> str: transcribe_kwargs = { @@ -189,41 +200,39 @@ def transcribe(self, audio_source: Path | str) -> str: if self.language: transcribe_kwargs["language"] = self.language - timer = TimerLine("🔄 [bold blue]Transcribing ... {elapsed}[/bold blue]") + timer = TimerLine("[cyan]{spinner}[/cyan] transcribing [dim]{elapsed}[/dim]") try: - with Live(timer, console=console, refresh_per_second=4, transient=True): + with live_line(timer): segments, _ = self.model.transcribe(str(audio_source), **transcribe_kwargs) transcription_parts = [text for segment in segments if (text := segment.text.strip())] except Exception as e: - raise RuntimeError(f"Transcription failed: {e}") from e + raise RuntimeError(f"transcription failed: {e}") from e full_transcription = " ".join(transcription_parts) if not full_transcription: - raise ValueError("No speech detected in audio") + raise ValueError("no speech detected in audio") - console.print(f"✅ [bold green]Transcribed in {format_duration(timer.elapsed)}[/bold green]") return full_transcription @classmethod def list_models(cls): - console.print("ℹ️ [bold cyan]Available Whisper models:[/bold cyan]") for model in cls.VALID_MODELS: click.echo(model) - console.print("\nℹ️ [bold cyan]Environment variables:[/bold cyan]") - console.print(" [dim]export HNS_WHISPER_MODEL=[/dim]") - console.print(" [dim]export HNS_LANG= # e.g., en, es, fr[/dim]") - console.print(" [dim]export HNS_CACHE_DIR= # override last_recording.wav location[/dim]") + console.print("\n[dim]environment variables:[/dim]") + console.print("[dim] HNS_WHISPER_MODEL= default model (base)[/dim]") + console.print("[dim] HNS_LANG= e.g. en, es, fr[/dim]") + console.print("[dim] HNS_CACHE_DIR= last_recording.wav location[/dim]") -def copy_to_clipboard(text: str): +def copy_to_clipboard(text: str) -> bool: try: pyperclip.copy(text) except Exception as e: - console.print(f"⚠️ [bold yellow]Failed to copy to clipboard: {e}[/bold yellow]") + console.print(f"[yellow]![/yellow] clipboard copy failed: {escape(str(e))}") if sys.platform.startswith("linux"): - console.print(" [dim]Install xclip, xsel, or wl-clipboard to enable clipboard support[/dim]") - return - console.print("✅ [bold green]Copied to clipboard![/bold green]") + console.print(" [dim]install xclip, xsel, or wl-clipboard for clipboard support[/dim]") + return False + return True @click.command() @@ -246,31 +255,42 @@ def main(model: str | None, language: str | None, last: bool, no_copy: bool, lis return try: + recorded_duration = None if last: audio_file_path = get_audio_file_path() if not audio_file_path.exists(): - console.print( - "❌ [bold red]No previous recording found. Record audio first by running 'hns' without --last flag.[/bold red]" - ) + console.print("[red]✗[/red] no previous recording found · run hns without --last to record one") sys.exit(1) else: - audio_file_path = AudioRecorder().record() + recorder = AudioRecorder() + audio_file_path = recorder.record() + recorded_duration = recorder.duration + transcriber = WhisperTranscriber(model_name=model, language=language) + transcribe_start = time.time() transcription = transcriber.transcribe(audio_file_path) - - if not no_copy: - copy_to_clipboard(transcription) - + transcribe_seconds = time.time() - transcribe_start + + copied = copy_to_clipboard(transcription) if not no_copy else False + + summary = [] + if recorded_duration is not None: + summary.append(f"recorded {format_duration(recorded_duration)}") + summary.append(f"transcribed in {transcribe_seconds:.1f}s") + if copied: + summary.append("copied to clipboard") + console.print(f"[green]✓[/green] [dim]{' · '.join(summary)}[/dim]") + console.print() click.echo(transcription) except KeyboardInterrupt: - console.print("⏹️ [bold yellow]Cancelled[/bold yellow]") + console.print("[yellow]![/yellow] cancelled") sys.exit(130) except (RuntimeError, ValueError) as e: - console.print(f"❌ [bold red]{e}[/bold red]") + console.print(f"[red]✗[/red] {escape(str(e))}") sys.exit(1) except Exception as e: - console.print(f"❌ [bold red]Unexpected error: {e}[/bold red]") + console.print(f"[red]✗[/red] unexpected error: {escape(str(e))}") sys.exit(1) From 615f4169b2d1641337ec4b4197a4f7db2d4d08cd Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:32:27 +0900 Subject: [PATCH 09/11] distinguish model download from load in status --- hns/cli.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index 1dfe5ca..85c05ac 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -2,6 +2,7 @@ import sys import time import wave +from contextlib import nullcontext from pathlib import Path import click @@ -180,12 +181,33 @@ def _get_model_name(self, model_name: str | None) -> str: return model + def _is_model_cached(self) -> bool: + import logging + + from faster_whisper.utils import download_model + + logger = logging.getLogger("faster_whisper") + original_level = logger.level + logger.setLevel(logging.ERROR) + try: + download_model(self.model_name, local_files_only=True) + return True + except Exception: + return False + finally: + logger.setLevel(original_level) + def _load_model(self): from faster_whisper import WhisperModel - timer = TimerLine(f"[cyan]{{spinner}}[/cyan] loading {self.model_name} model") + if self._is_model_cached(): + context = live_line(TimerLine(f"[cyan]{{spinner}}[/cyan] loading {self.model_name} model")) + else: + console.print(f"[cyan]↓[/cyan] downloading {self.model_name} model [dim]· first run only[/dim]") + context = nullcontext() + try: - with live_line(timer): + with context: return WhisperModel(self.model_name, device="cpu", compute_type="int8") except Exception as e: raise RuntimeError(f"failed to load model '{self.model_name}': {e}") from e From 38b7108531a9bd44262a0ee2ff25bc3a6e716f79 Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:33:07 +0900 Subject: [PATCH 10/11] add -q/--quiet flag --- hns/cli.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/hns/cli.py b/hns/cli.py index 85c05ac..1ce6d1e 100644 --- a/hns/cli.py +++ b/hns/cli.py @@ -15,6 +15,12 @@ console = Console(stderr=True) SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" +QUIET = False + + +def status_print(message: str = ""): + if not QUIET: + console.print(message) def format_duration(seconds: float) -> str: @@ -44,7 +50,9 @@ def __rich__(self) -> Text: return Text.from_markup(self.template.format(elapsed=format_duration(self.elapsed), spinner=spinner)) -def live_line(line: TimerLine) -> Live: +def live_line(line: TimerLine) -> Live | nullcontext: + if QUIET: + return nullcontext() return Live(line, console=console, refresh_per_second=10, transient=True) @@ -203,7 +211,7 @@ def _load_model(self): if self._is_model_cached(): context = live_line(TimerLine(f"[cyan]{{spinner}}[/cyan] loading {self.model_name} model")) else: - console.print(f"[cyan]↓[/cyan] downloading {self.model_name} model [dim]· first run only[/dim]") + status_print(f"[cyan]↓[/cyan] downloading {self.model_name} model [dim]· first run only[/dim]") context = nullcontext() try: @@ -268,10 +276,16 @@ def copy_to_clipboard(text: str) -> bool: @click.option("--language", help="Force language detection (e.g., en, es, fr). Can also use HNS_LANG env var") @click.option("--last", is_flag=True, help="Transcribe the last recorded audio file") @click.option("--no-copy", is_flag=True, help="Do not copy the transcription to the clipboard") +@click.option("--quiet", "-q", is_flag=True, help="Suppress status output; warnings and errors still print") @click.option("--list-models", is_flag=True, help="List available Whisper models and exit") -def main(model: str | None, language: str | None, last: bool, no_copy: bool, list_models: bool): +def main(model: str | None, language: str | None, last: bool, no_copy: bool, quiet: bool, list_models: bool): """Record audio from microphone, transcribe it, and copy to clipboard.""" + global QUIET + QUIET = quiet + if quiet: + os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + if list_models: WhisperTranscriber.list_models() return @@ -301,8 +315,8 @@ def main(model: str | None, language: str | None, last: bool, no_copy: bool, lis summary.append(f"transcribed in {transcribe_seconds:.1f}s") if copied: summary.append("copied to clipboard") - console.print(f"[green]✓[/green] [dim]{' · '.join(summary)}[/dim]") - console.print() + status_print(f"[green]✓[/green] [dim]{' · '.join(summary)}[/dim]") + status_print() click.echo(transcription) except KeyboardInterrupt: From 8712a34171710d7c6ee3bfae91ede5c6e707425c Mon Sep 17 00:00:00 2001 From: Prashant Anand Date: Mon, 20 Jul 2026 13:33:28 +0900 Subject: [PATCH 11/11] update agent guide for status conventions and --quiet --- AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd9d7ae..b43dd82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ There is no full automated test suite. For behavior changes, run `make test-help - `--language`: CLI override for transcription language. - `--last`: retranscribes the cached last recording instead of recording new audio. - `--no-copy`: skips copying the transcription to the clipboard. +- `-q`/`--quiet`: suppresses status output on stderr; warnings and errors still print. - `--list-models`: prints model names to stdout; usage hints go to stderr. - `--version`: prints the installed version. @@ -56,7 +57,8 @@ Cached audio is written to the platform cache directory as `last_recording.wav`. ## Code Organization - `format_duration`: shared timer formatting. -- `TimerLine`: elapsed-time renderable for `rich` `Live` status displays. +- `TimerLine`: elapsed-time and spinner renderable for `rich` `Live` status displays. +- `status_print`/`live_line`: status output helpers gated by the `--quiet` flag. - `get_audio_file_path`: resolves the cross-platform cache path for `last_recording.wav`. - `AudioRecorder`: validates input device, records microphone audio, writes WAV data, manages cache path. - `WhisperTranscriber`: validates model names, loads `faster-whisper`, transcribes audio, lists models. @@ -73,8 +75,9 @@ Cached audio is written to the platform cache directory as `last_recording.wav`. ## Conventions - Keep implementation compact; avoid adding modules unless complexity clearly justifies it. -- Preserve stdout for machine-readable transcription output only. +- Preserve stdout for machine-readable transcription output only; write it raw via `click.echo`, never through `rich`, so piped output is unwrapped and unstyled. - Preserve stderr for progress, errors, warnings, and status messages through the `rich` stderr console. +- Status messages use plain text glyphs, not emojis: `●` recording, spinner frames for in-progress work, `✓` success, `!` warning, `✗` error, `↓` download. - Prefer explicit, user-friendly CLI errors over tracebacks. - Keep dependencies minimal and justified; this is a simple CLI distributed on PyPI. - Manage dependencies with `uv add` or `uv add --dev`; do not edit `pyproject.toml` directly for dependency changes.