|
1 | 1 | """Recording command for the shh CLI.""" |
2 | 2 |
|
3 | | -import asyncio |
4 | | -import contextlib |
5 | 3 | import logging |
6 | 4 | import sys |
7 | 5 |
|
8 | | -import pyperclip # type: ignore[import-untyped] |
9 | | -from rich.console import Console |
10 | | -from rich.live import Live |
11 | | -from rich.panel import Panel |
12 | | -from rich.text import Text |
13 | | - |
14 | | -from shh.adapters.audio.processor import save_audio_to_wav |
15 | | -from shh.adapters.audio.recorder import AudioRecorder |
16 | | -from shh.adapters.llm.formatter import format_transcription |
17 | | -from shh.adapters.whisper.client import transcribe_audio |
| 6 | +from shh.cli.ui import QuietUI, RichUI, UIOutput |
| 7 | +from shh.cli.ui.base import RecordingProgress, TranscriptionResult |
18 | 8 | from shh.config.settings import Settings |
| 9 | +from shh.core.models import RecordingOptions |
19 | 10 | from shh.core.styles import TranscriptionStyle |
| 11 | +from shh.services.recording import RecordingService |
20 | 12 |
|
21 | 13 | # Suppress HTTP request logs |
22 | 14 | logging.getLogger("httpx").setLevel(logging.WARNING) |
23 | 15 | logging.getLogger("openai").setLevel(logging.WARNING) |
24 | 16 |
|
25 | | -console = Console() |
26 | | - |
27 | 17 |
|
28 | | -async def wait_for_enter() -> None: |
29 | | - """Wait for user to press Enter (runs in thread pool).""" |
30 | | - loop = asyncio.get_running_loop() |
31 | | - await loop.run_in_executor(None, sys.stdin.readline) |
| 18 | +def _get_ui(quiet: bool, verbose: bool, settings: Settings) -> UIOutput: |
| 19 | + """ |
| 20 | + Determine which UI to use based on flags and config. |
| 21 | + |
| 22 | + Priority: verbose flag > quiet flag > config setting |
| 23 | + |
| 24 | + Args: |
| 25 | + quiet: Force quiet mode |
| 26 | + verbose: Force verbose mode |
| 27 | + settings: User settings |
| 28 | + |
| 29 | + Returns: |
| 30 | + UIOutput instance (RichUI or QuietUI) |
| 31 | + """ |
| 32 | + if verbose: |
| 33 | + return RichUI() |
| 34 | + if quiet: |
| 35 | + return QuietUI() |
| 36 | + return QuietUI() if settings.quiet_mode else RichUI() |
32 | 37 |
|
33 | 38 |
|
34 | 39 | async def record_command( |
35 | 40 | style: TranscriptionStyle | None = None, |
36 | 41 | translate: str | None = None, |
| 42 | + quiet: bool = False, |
| 43 | + verbose: bool = False, |
37 | 44 | ) -> None: |
38 | 45 | """ |
39 | 46 | Record audio, transcribe, and optionally format/translate. |
40 | 47 |
|
41 | 48 | Args: |
42 | 49 | style: Formatting style to apply (overrides config default) |
43 | 50 | translate: Target language for translation |
| 51 | + quiet: Force minimal output (overrides config) |
| 52 | + verbose: Force rich UI (overrides config) |
44 | 53 | """ |
45 | 54 | # Load settings |
46 | 55 | settings = Settings.load_from_file() |
47 | 56 | if not settings or not settings.openai_api_key: |
48 | | - console.print("[red]Error: No API key found.[/red]") |
49 | | - console.print("[dim]Run 'shh setup' to configure your OpenAI API key.[/dim]") |
| 57 | + # For error message, default to Rich UI unless quiet explicitly set |
| 58 | + ui: UIOutput = QuietUI() if quiet else RichUI() |
| 59 | + ui.show_error( |
| 60 | + "No API key found.", |
| 61 | + "Run 'shh setup' to configure your OpenAI API key.", |
| 62 | + ) |
50 | 63 | sys.exit(1) |
51 | 64 |
|
52 | 65 | # Use provided options or fall back to config defaults |
53 | 66 | formatting_style = style if style is not None else settings.default_style |
54 | 67 | target_language = translate if translate is not None else settings.default_translation_language |
| 68 | + ui = _get_ui(quiet, verbose, settings) |
55 | 69 |
|
56 | | - # Step 1: Recording |
57 | | - console.print() |
| 70 | + # Create service |
| 71 | + service = RecordingService(settings) |
| 72 | + options = RecordingOptions( |
| 73 | + style=formatting_style, |
| 74 | + translate=target_language, |
| 75 | + show_progress=settings.show_progress, |
| 76 | + ) |
58 | 77 |
|
59 | 78 | try: |
60 | | - async with AudioRecorder() as recorder: |
61 | | - # Start waiting for Enter in background |
62 | | - enter_task = asyncio.create_task(wait_for_enter()) |
63 | | - |
64 | | - # Show live progress |
65 | | - with Live(auto_refresh=False, console=console) as live: |
66 | | - while not enter_task.done() and not recorder.is_max_duration_reached(): |
67 | | - elapsed = recorder.elapsed_time() |
68 | | - max_duration = recorder._max_duration |
69 | | - |
70 | | - # Create progress text |
71 | | - progress = Text() |
72 | | - progress.append("Recording... ", style="bold green") |
73 | | - progress.append(f"{elapsed:.1f}s ", style="cyan") |
74 | | - progress.append(f"/ {max_duration:.0f}s ", style="dim") |
75 | | - progress.append("[Press Enter to stop]", style="dim") |
76 | | - |
77 | | - live.update(progress) |
78 | | - live.refresh() |
79 | | - await asyncio.sleep(0.1) |
80 | | - |
81 | | - # Check if max duration reached |
82 | | - if recorder.is_max_duration_reached(): |
83 | | - console.print( |
84 | | - "\n[yellow]Maximum recording duration reached (5 minutes)[/yellow]" |
85 | | - ) |
86 | | - |
87 | | - # Cancel Enter task if we hit max duration |
88 | | - if not enter_task.done(): |
89 | | - enter_task.cancel() |
90 | | - with contextlib.suppress(asyncio.CancelledError): |
91 | | - await enter_task |
92 | | - |
93 | | - # Get recorded audio |
94 | | - audio_data = recorder.get_audio() |
| 79 | + # Recording phase |
| 80 | + ui.show_recording_start() |
95 | 81 |
|
96 | | - except KeyboardInterrupt: |
97 | | - console.print("\n[yellow]Recording cancelled.[/yellow]") |
98 | | - sys.exit(130) # Standard exit code for Ctrl+C |
| 82 | + def progress_callback(elapsed: float, max_duration: float) -> None: |
| 83 | + ui.show_recording_progress(RecordingProgress(elapsed, max_duration)) |
99 | 84 |
|
100 | | - # Check if we got any audio |
101 | | - if len(audio_data) == 0: |
102 | | - console.print("[yellow]No audio recorded.[/yellow]") |
103 | | - sys.exit(1) |
| 85 | + # Always pass progress callback - UI decides how to display it |
| 86 | + audio_data = await service.record_audio(on_progress=progress_callback) |
104 | 87 |
|
105 | | - # Step 2: Save to WAV |
106 | | - console.print("\n[cyan]Saving audio...[/cyan]") |
107 | | - audio_file_path = save_audio_to_wav(audio_data) |
| 88 | + ui.show_recording_stopped() |
108 | 89 |
|
109 | | - try: |
110 | | - # Step 3: Transcribe |
111 | | - console.print("[cyan]Transcribing with Whisper...[/cyan]") |
112 | | - transcription = await transcribe_audio( |
113 | | - audio_file_path=audio_file_path, |
114 | | - api_key=settings.openai_api_key, |
115 | | - ) |
| 90 | + # Check if we got any audio |
| 91 | + if len(audio_data) == 0: |
| 92 | + ui.show_warning("No audio recorded.") |
| 93 | + sys.exit(1) |
| 94 | + |
| 95 | + # Processing phases |
| 96 | + ui.show_processing_step("Saving audio...") |
| 97 | + ui.show_processing_step("Transcribing with Whisper...") |
116 | 98 |
|
117 | | - # Step 4: Format/Translate (if requested) |
118 | 99 | if formatting_style != TranscriptionStyle.NEUTRAL or target_language: |
119 | 100 | if target_language: |
120 | | - console.print(f"[cyan]Formatting and translating to {target_language}...[/cyan]") |
| 101 | + ui.show_processing_step(f"Formatting and translating to {target_language}...") |
121 | 102 | else: |
122 | | - console.print(f"[cyan]Formatting ({formatting_style})...[/cyan]") |
123 | | - |
124 | | - formatted = await format_transcription( |
125 | | - transcription, |
126 | | - style=formatting_style, |
127 | | - api_key=settings.openai_api_key, |
128 | | - target_language=target_language, |
| 103 | + ui.show_processing_step(f"Formatting ({formatting_style})...") |
| 104 | + |
| 105 | + # Transcribe and format |
| 106 | + result = await service.transcribe_and_format(audio_data, options) |
| 107 | + |
| 108 | + # Display result |
| 109 | + ui.show_result( |
| 110 | + TranscriptionResult( |
| 111 | + text=result.text, |
| 112 | + copied_to_clipboard=result.copied_to_clipboard, |
| 113 | + style=result.style.value, |
| 114 | + translated_to=result.translated_to, |
129 | 115 | ) |
130 | | - final_text = formatted.text |
131 | | - else: |
132 | | - final_text = transcription |
133 | | - |
134 | | - # Step 5: Copy to clipboard |
135 | | - clipboard_success = True |
136 | | - try: |
137 | | - pyperclip.copy(final_text) |
138 | | - except Exception as e: |
139 | | - clipboard_success = False |
140 | | - console.print(f"[yellow]Warning: Could not copy to clipboard: {e}[/yellow]") |
141 | | - |
142 | | - # Step 6: Display result |
143 | | - console.print() |
144 | | - result_panel = Panel( |
145 | | - final_text, |
146 | | - title="Transcription" + (" (copied to clipboard)" if clipboard_success else ""), |
147 | | - border_style="green" if clipboard_success else "yellow", |
148 | 116 | ) |
149 | | - console.print(result_panel) |
150 | | - console.print() |
151 | 117 |
|
| 118 | + except KeyboardInterrupt: |
| 119 | + ui.show_warning("Recording cancelled.") |
| 120 | + sys.exit(130) # Standard exit code for Ctrl+C |
| 121 | + except ValueError as e: |
| 122 | + ui.show_error(str(e)) |
| 123 | + sys.exit(1) |
152 | 124 | finally: |
153 | | - # Cleanup temp file |
154 | | - audio_file_path.unlink(missing_ok=True) |
| 125 | + ui.cleanup() |
0 commit comments