From 2e9a0b9bdb87dd4c316f89234d4b3401faa02fac Mon Sep 17 00:00:00 2001 From: Alireza Eliaderani <172368+cubny@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:34:11 +0200 Subject: [PATCH 1/6] feat: improve the runtime interface --- README.md | 26 +++ src/focus/audio/output.py | 62 ++++- src/focus/cli.py | 471 +++++++++++++++++++++++++------------- src/focus/ui/__init__.py | 5 + src/focus/ui/launcher.py | 92 ++++++++ src/focus/ui/transport.py | 195 ++++++++++++++++ tests/test_ui.py | 259 +++++++++++++++++++++ 7 files changed, 953 insertions(+), 157 deletions(-) create mode 100644 src/focus/ui/__init__.py create mode 100644 src/focus/ui/launcher.py create mode 100644 src/focus/ui/transport.py create mode 100644 tests/test_ui.py diff --git a/README.md b/README.md index b8595c1..8e72cf7 100644 --- a/README.md +++ b/README.md @@ -130,13 +130,39 @@ pip install -e ".[dev]" ## Usage +The fastest way to start: just run `focus` in a terminal. With no arguments it +opens an interactive picker — use ↑/↓ (or number keys) to choose a profile and +Enter to start. No need to memorize profile names or flags. + ```bash +# Interactive launcher (pick a profile, then play) +focus + # Start a focus session with default profile focus start --profile deep-work # List available profiles focus profiles +``` + +### Playback controls +While a session is playing in a terminal, single keys control playback (a status +line at the bottom shows what's playing and the available keys): + +| Key | Action | +|-----|--------| +| `space` / `p` | Pause / resume (reconnects with a fresh take on resume) | +| `n` | Next take — regenerate fresh music in the same profile | +| `↑` / `↓` (or `+` / `-`) | Volume up / down | +| `?` | Toggle expanded key help | +| `q` | Quit | +| `Ctrl+C` | Hard stop | + +These controls activate only when running attached to a terminal; piped or +recording-only runs (`-o file.wav` in a script) behave exactly as before. + +```bash # Custom modulation settings focus start --frequency 16 --depth 0.3 --prompt "ambient electronic..." diff --git a/src/focus/audio/output.py b/src/focus/audio/output.py index cae5108..3f65c6e 100644 --- a/src/focus/audio/output.py +++ b/src/focus/audio/output.py @@ -36,11 +36,13 @@ class AudioOutput: blocksize: int = 2048 # ~42ms per block at 48kHz buffersize: int = 200 # Queue capacity in blocks (~8.5 seconds at 48kHz) minimum_buffer_seconds: float = 3.0 # Pre-fill buffer before starting playback + volume: float = 1.0 # Output gain, 0.0-1.0 (attenuation only) _stream: object = field(default=None, init=False, repr=False) _queue: queue.Queue = field(default=None, init=False, repr=False) _running: bool = field(default=False, init=False) _started: bool = field(default=False, init=False) + _paused: bool = field(default=False, init=False) _stream_active: bool = field(default=False, init=False) _leftover: np.ndarray | None = field(default=None, init=False, repr=False) _underrun_count: int = field(default=0, init=False) @@ -77,7 +79,7 @@ def start(self) -> None: def _maybe_start_stream(self) -> None: """Start the actual audio stream if we have enough buffer pre-filled.""" - if self._stream_active or not self._running: + if self._stream_active or not self._running or self._paused: return # Check if we have enough buffer @@ -143,6 +145,12 @@ def _audio_callback(self, outdata: np.ndarray, frames: int, time_info, status) - # Silent output after fade completes outdata[:] = 0 + # Final output gain (attenuation only; applied after the DSP limiter so + # it can never re-introduce clipping). _last_good_block stays pre-gain + # so volume changes don't compound across underrun recovery. + if self.volume != 1.0: + outdata *= self.volume + def write(self, audio: np.ndarray) -> None: """Write audio data to the output buffer. @@ -230,6 +238,47 @@ def stop(self) -> None: self._recovery_fade_pos = 0 self._in_underrun = False + def set_volume(self, volume: float) -> None: + """Set the output gain. + + Args: + volume: Gain in [0.0, 1.0]. Attenuation only; values are clamped. + """ + self.volume = max(0.0, min(1.0, volume)) + + def _drain_queue(self) -> None: + """Discard all buffered blocks (used when pausing).""" + if self._queue is None: + return + while not self._queue.empty(): + try: + self._queue.get_nowait() + except queue.Empty: + break + + def pause(self) -> None: + """Pause playback by tearing down the stream and dropping the buffer. + + The object stays alive; the next ``write()`` calls re-fill the buffer and + ``_maybe_start_stream`` recreates the stream once enough is buffered (so + resume is click-free, just like initial start). Call ``resume()`` first. + """ + self._paused = True + if self._stream: + self._stream.stop() + self._stream.close() + self._stream = None + self._stream_active = False + self._drain_queue() + self._leftover = None + self._in_underrun = False + self._underrun_fade_pos = 0 + self._recovery_fade_pos = 0 + + def resume(self) -> None: + """Resume playback. The stream is recreated lazily once the buffer re-fills.""" + self._paused = False + @property def underrun_count(self) -> int: """Number of buffer underruns detected during playback.""" @@ -260,7 +309,9 @@ def __init__(self, sample_rate: int = 48000, channels: int = 2): self.sample_rate = sample_rate self.channels = channels self.written_samples = 0 + self.volume = 1.0 self._running = False + self._paused = False self._underrun_count = 0 def start(self) -> None: @@ -277,6 +328,15 @@ def flush(self) -> None: def stop(self) -> None: self._running = False + def set_volume(self, volume: float) -> None: + self.volume = max(0.0, min(1.0, volume)) + + def pause(self) -> None: + self._paused = True + + def resume(self) -> None: + self._paused = False + @property def underrun_count(self) -> int: return self._underrun_count diff --git a/src/focus/cli.py b/src/focus/cli.py index 759e3f8..8cde2c4 100644 --- a/src/focus/cli.py +++ b/src/focus/cli.py @@ -10,6 +10,7 @@ from focus.dsp.spatial import ReverbState, apply_reverb, apply_stereo_widening from focus.generation.lyria_client import LyriaConfig, create_client from focus.profiles import FocusProfile, get_profile, list_profiles +from focus.ui.transport import KeyboardController, PlaybackState, StatusLine # Check for optional dependencies try: @@ -21,20 +22,35 @@ AUDIO_AVAILABLE = False -@click.group() +@click.group(invoke_without_command=True) @click.version_option(version="0.1.0") -def main(): +@click.pass_context +def main(ctx): """Focus - Neural entrainment music generator. Generate focus-enhancing music using AI (Google Lyria) with neural entrainment modulation for improved concentration. + Run `focus` with no arguments in a terminal to pick a profile interactively. + Quick Usage:\n focus start --profile deep-work \n focus start --duration 600 # 10 minute session \n focus start --output session.wav \n """ - pass + if ctx.invoked_subcommand is not None: + return + + # Bare invocation: drop into the interactive picker when attached to a + # terminal; otherwise (pipes, CI) fall back to the usual help text. + if sys.stdin.isatty() and sys.stdout.isatty(): + from focus.ui.launcher import run_launcher + + choice = run_launcher() + if choice: + launch_session(profile=choice) + else: + click.echo(ctx.get_help()) @main.command("profiles") @@ -156,6 +172,41 @@ def start_session( focus start --frequency 16 --depth 0.3 --mock """ + launch_session( + profile=profile, + frequency=frequency, + depth=depth, + prompt=prompt, + mock=mock, + duration=duration, + output=output, + reverb=reverb, + stereo_width=stereo_width, + limiter=limiter, + verbose=verbose, + track_duration=track_duration, + ) + + +def launch_session( + profile: str, + frequency: float | None = None, + depth: float | None = None, + prompt: str | None = None, + mock: bool = False, + duration: int | None = None, + output: str | None = None, + reverb: bool = True, + stereo_width: float = 1.2, + limiter: bool = True, + verbose: bool = False, + track_duration: int = 9, +): + """Resolve a profile, apply overrides, and run a session. + + Shared entry point for both the ``start`` command and the bare-``focus`` + interactive launcher. + """ if duration is not None and duration < 60: click.echo( "Error: Duration must be at least 60 seconds to allow for intro/outro phases.", @@ -201,7 +252,10 @@ def start_session( click.echo(f" Duration: {duration} seconds") if output: click.echo(f" Output: {output}") - click.echo("\n Press Ctrl+C to stop\n") + if sys.stdin.isatty() and sys.stdout.isatty(): + click.echo("\n Controls: [space] pause [n] next take [↑↓] volume [?] help [q] quit\n") + else: + click.echo("\n Press Ctrl+C to stop\n") try: asyncio.run( @@ -244,24 +298,42 @@ async def _run_session( click.echo("Error: sounddevice not available", err=True) return - config = LyriaConfig( - prompt=profile.prompt, - bpm=profile.bpm or 120, - density=profile.density or 0.5, - brightness=profile.brightness or 0.5, - ) + sample_rate = 48000 # Clamp track duration to valid range (1-9 minutes) track_duration_seconds = max(60, min(9 * 60, track_duration * 60)) - client = create_client( - config, - use_mock=use_mock, - verbose=verbose, - session_duration=track_duration_seconds, - ) + def build_config(phase: str) -> LyriaConfig: + """Build a Lyria config for the given musical phase.""" + bpm = profile.bpm or 120 + density = profile.density or 0.5 + brightness = profile.brightness or 0.5 + if phase == "intro" and profile.intro_prompt: + return LyriaConfig( + prompt=f"{profile.intro_prompt}, {profile.prompt}", + bpm=bpm, + density=max(0.1, density - 0.2), # Start with lower density + brightness=brightness, + ) + if phase == "outro" and profile.outro_prompt: + return LyriaConfig( + prompt=f"{profile.outro_prompt}, {profile.prompt}", + bpm=bpm, + density=density, + brightness=brightness, + ) + return LyriaConfig(prompt=profile.prompt, bpm=bpm, density=density, brightness=brightness) + + def make_client(phase: str): + return create_client( + build_config(phase), + use_mock=use_mock, + verbose=verbose, + session_duration=track_duration_seconds, + ) if not AUDIO_AVAILABLE: + client = make_client("main") click.echo("⚠️ sounddevice not available, running in test mode") await client.connect() chunk_count = 0 @@ -276,12 +348,39 @@ async def _run_session( await client.stop() return - # Initialize state + # Interactive transport controls (only attached to a real terminal) + interactive = sys.stdin.isatty() and sys.stdout.isatty() + state = None + keyboard = None + status_line = None + if interactive: + state = PlaybackState( + profile_name=profile.name, + modulation_freq=profile.modulation_freq, + modulation_depth=profile.modulation_depth, + status="connecting", + ) + if not verbose: + # The live status line and -v logging both want the bottom line; + # when verbose, the logs already convey state, so skip the line. + status_line = StatusLine() + status_line.start() + status_line.render(state) + + # Redraw immediately on each keypress so pause / volume / help toggles + # are reflected instantly instead of on the next audio chunk. + def _on_key(): + if status_line is not None: + status_line.render(state) + + keyboard = KeyboardController(state, on_change=_on_key) + keyboard.start() + + # Initialize DSP state mod_state = ModulationState() reverb_state = ReverbState() if reverb else None limiter_state = LimiterState(ceiling_linear=0.989) if limiter else None # -0.1 dBTP - sample_rate = 48000 chunk_count = 0 total_seconds = 0.0 @@ -298,17 +397,6 @@ async def _run_session( phase_switched_to_main = current_phase == "main" phase_switched_to_outro = False - # Build initial prompt with intro modifier if timed session - if duration and profile.intro_prompt and current_phase == "intro": - initial_prompt = f"{profile.intro_prompt}, {profile.prompt}" - config = LyriaConfig( - prompt=initial_prompt, - bpm=profile.bpm or 120, - density=max(0.1, (profile.density or 0.5) - 0.2), # Start with lower density - brightness=profile.brightness or 0.5, - ) - client = create_client(config, use_mock=use_mock, verbose=verbose) - if verbose: click.echo(f" 🔊 Audio device: {sd.query_devices(sd.default.device[1])['name']}") if duration: @@ -318,6 +406,7 @@ async def _run_session( ) # Connect to generator + client = make_client(current_phase) await client.connect() if verbose: click.echo(" ✓ Connected to audio generator") @@ -332,145 +421,210 @@ async def _run_session( file_output = FileAudioOutput(filepath=output_path, sample_rate=sample_rate) file_output.start() + session_complete = False + try: - async for chunk in client.generate_stream(): - chunk_count += 1 - chunk_seconds = len(chunk) / sample_rate - total_seconds += chunk_seconds - - if verbose: - # Log amplitude to verify signal presence - max_amp = np.max(np.abs(chunk)) - click.echo( - f" 📦 Chunk {chunk_count}: {len(chunk)} samples, " - f"max_amp={max_amp:.3f}, phase={current_phase}" - ) + # Outer loop: each iteration consumes one generator until it ends or an + # interactive control (pause / next take) asks us to reconnect. + while not session_complete: + stream = client.generate_stream() + reconnect = False - # Phase transitions for timed sessions - if duration: - # Transition: intro -> main (after intro_duration) - if ( - current_phase == "intro" - and total_seconds >= intro_duration - and not phase_switched_to_main - ): - current_phase = "main" - phase_switched_to_main = True - await client.set_prompt(profile.prompt) - if verbose: - click.echo(" 🎵 Phase transition: intro → main") - - # Transition: main -> outro (outro_duration before end) - time_remaining = duration - total_seconds - if ( - current_phase == "main" - and time_remaining <= outro_duration - and not phase_switched_to_outro - ): - if profile.outro_prompt: - current_phase = "outro" - phase_switched_to_outro = True - outro_full_prompt = f"{profile.outro_prompt}, {profile.prompt}" - await client.set_prompt(outro_full_prompt) - if verbose: - click.echo(" 🎵 Phase transition: main → outro") - - # Apply neural entrainment - modulated, mod_state = apply_entrainment( - chunk, - sample_rate, - target_freq=profile.modulation_freq, - depth=profile.modulation_depth, - state=mod_state, - ) + async for chunk in stream: + chunk_count += 1 + chunk_seconds = len(chunk) / sample_rate + total_seconds += chunk_seconds - # Apply fade-in to early chunks - if fade_in_samples_remaining > 0: - chunk_samples = len(modulated) - if fade_in_samples_remaining >= chunk_samples: - # This entire chunk needs fading - fade_progress = 1.0 - ( - fade_in_samples_remaining / (fade_duration * sample_rate) - ) - end_progress = fade_progress + chunk_samples / (fade_duration * sample_rate) - t = np.linspace( - fade_progress * np.pi / 2, - end_progress * np.pi / 2, - chunk_samples, - ) - envelope = np.sin(t) ** 2 - if modulated.ndim == 2: - modulated = modulated * envelope[:, np.newaxis] - else: - modulated = modulated * envelope - modulated = modulated.astype(np.float32) - else: - # Partial fade on this chunk - fade_progress = 1.0 - ( - fade_in_samples_remaining / (fade_duration * sample_rate) - ) - t = np.linspace( - fade_progress * np.pi / 2, - np.pi / 2, - fade_in_samples_remaining, + if verbose: + # Log amplitude to verify signal presence + max_amp = np.max(np.abs(chunk)) + click.echo( + f" 📦 Chunk {chunk_count}: {len(chunk)} samples, " + f"max_amp={max_amp:.3f}, phase={current_phase}" ) - envelope = np.sin(t) ** 2 - if modulated.ndim == 2: - modulated[:fade_in_samples_remaining] *= envelope[:, np.newaxis] - else: - modulated[:fade_in_samples_remaining] *= envelope - modulated = modulated.astype(np.float32) - fade_in_samples_remaining -= chunk_samples - # --- Phase 3 DSP Chain --- + # Phase transitions for timed sessions + if duration: + # Transition: intro -> main (after intro_duration) + if ( + current_phase == "intro" + and total_seconds >= intro_duration + and not phase_switched_to_main + ): + current_phase = "main" + phase_switched_to_main = True + await client.set_prompt(profile.prompt) + if verbose: + click.echo(" 🎵 Phase transition: intro → main") + + # Transition: main -> outro (outro_duration before end) + time_remaining = duration - total_seconds + if ( + current_phase == "main" + and time_remaining <= outro_duration + and not phase_switched_to_outro + ): + if profile.outro_prompt: + current_phase = "outro" + phase_switched_to_outro = True + outro_full_prompt = f"{profile.outro_prompt}, {profile.prompt}" + await client.set_prompt(outro_full_prompt) + if verbose: + click.echo(" 🎵 Phase transition: main → outro") + + # Apply neural entrainment + modulated, mod_state = apply_entrainment( + chunk, + sample_rate, + target_freq=profile.modulation_freq, + depth=profile.modulation_depth, + state=mod_state, + ) - # 1. Spatialization (Reverb) - if reverb: - modulated, reverb_state = apply_reverb(modulated, sample_rate, state=reverb_state) + # Apply fade-in to early chunks + if fade_in_samples_remaining > 0: + chunk_samples = len(modulated) + if fade_in_samples_remaining >= chunk_samples: + # This entire chunk needs fading + fade_progress = 1.0 - ( + fade_in_samples_remaining / (fade_duration * sample_rate) + ) + end_progress = fade_progress + chunk_samples / (fade_duration * sample_rate) + t = np.linspace( + fade_progress * np.pi / 2, + end_progress * np.pi / 2, + chunk_samples, + ) + envelope = np.sin(t) ** 2 + if modulated.ndim == 2: + modulated = modulated * envelope[:, np.newaxis] + else: + modulated = modulated * envelope + modulated = modulated.astype(np.float32) + else: + # Partial fade on this chunk + fade_progress = 1.0 - ( + fade_in_samples_remaining / (fade_duration * sample_rate) + ) + t = np.linspace( + fade_progress * np.pi / 2, + np.pi / 2, + fade_in_samples_remaining, + ) + envelope = np.sin(t) ** 2 + if modulated.ndim == 2: + modulated[:fade_in_samples_remaining] *= envelope[:, np.newaxis] + else: + modulated[:fade_in_samples_remaining] *= envelope + modulated = modulated.astype(np.float32) + fade_in_samples_remaining -= chunk_samples + + # --- Phase 3 DSP Chain --- + + # 1. Spatialization (Reverb) + if reverb: + modulated, reverb_state = apply_reverb( + modulated, sample_rate, state=reverb_state + ) - # 2. Stereo Widening - if abs(stereo_width - 1.0) > 0.01: - modulated = apply_stereo_widening(modulated, width=stereo_width) + # 2. Stereo Widening + if abs(stereo_width - 1.0) > 0.01: + modulated = apply_stereo_widening(modulated, width=stereo_width) - # 4. Dynamics (Limiter) - if limiter: - modulated, limiter_state = apply_limiter( - modulated, sample_rate, state=limiter_state - ) + # 4. Dynamics (Limiter) + if limiter: + modulated, limiter_state = apply_limiter( + modulated, sample_rate, state=limiter_state + ) - # ALWAYS write to real-time output immediately (no buffering delay) - output.write(modulated) + # Output gain (volume) is applied inside AudioOutput, post-limiter + if state is not None: + output.set_volume(state.volume) + + # ALWAYS write to real-time output immediately (no buffering delay) + output.write(modulated) + + # For file output with duration: buffer the last 5 seconds for fade-out + if file_output: + if duration: + fade_out_buffer.append(modulated.copy()) + # Keep only enough buffer for fade-out duration + total_buffered = sum(len(c) for c in fade_out_buffer) + fade_out_samples = int(fade_duration * sample_rate) + while total_buffered > fade_out_samples and len(fade_out_buffer) > 1: + old_chunk = fade_out_buffer.pop(0) + total_buffered -= len(old_chunk) + # Write the old chunk that's no longer in fade zone + file_output.write(old_chunk) + else: + # No duration limit, write immediately to file + file_output.write(modulated) - # For file output with duration: buffer the last 5 seconds for fade-out - if file_output: - if duration: - fade_out_buffer.append(modulated.copy()) - # Keep only enough buffer for fade-out duration - total_buffered = sum(len(c) for c in fade_out_buffer) - fade_out_samples = int(fade_duration * sample_rate) - while total_buffered > fade_out_samples and len(fade_out_buffer) > 1: - old_chunk = fade_out_buffer.pop(0) - total_buffered -= len(old_chunk) - # Write the old chunk that's no longer in fade zone - file_output.write(old_chunk) - else: - # No duration limit, write immediately to file - file_output.write(modulated) - - # Check duration limit - if duration and total_seconds >= duration: - if verbose: - click.echo(f"\n ⏱️ Duration reached ({total_seconds:.1f}s)") - # Apply fade-out to file output's buffered chunks - if fade_out_buffer and file_output: - combined = np.concatenate(fade_out_buffer, axis=0) - faded = apply_fade_out(combined, sample_rate, fade_duration) - file_output.write(faded) - fade_out_buffer.clear() + # Check duration limit + if duration and total_seconds >= duration: + if verbose: + click.echo(f"\n ⏱️ Duration reached ({total_seconds:.1f}s)") + # Apply fade-out to file output's buffered chunks + if fade_out_buffer and file_output: + combined = np.concatenate(fade_out_buffer, axis=0) + faded = apply_fade_out(combined, sample_rate, fade_duration) + file_output.write(faded) + fade_out_buffer.clear() + session_complete = True + break + + # Interactive controls + if state is not None: + state.elapsed_seconds = total_seconds + state.buffer_seconds = output.buffer_seconds + state.status = "playing" + if status_line is not None: + status_line.render(state) + if state.quit_requested: + session_complete = True + break + if state.paused or state.skip_requested: + reconnect = True + break + + # Yield control to event loop to keep UI responsive + await asyncio.sleep(0) + + # Close the abandoned/finished generator before reconnecting + try: + await stream.aclose() + except Exception: + pass + + # End the session unless an interactive control asked to reconnect + if session_complete or state is None or not reconnect: break - # Yield control to event loop to keep UI responsive - await asyncio.sleep(0) + # Pause: tear the session down (stops burning quota), wait, reconnect + if state.paused: + output.pause() + await client.stop() + state.status = "paused" + if status_line is not None: + status_line.render(state) + while state.paused and not state.quit_requested: + await asyncio.sleep(0.15) + if status_line is not None: + status_line.render(state) + if state.quit_requested: + break + output.resume() + + # "Next take": force a fresh generation (same profile/phase) + state.skip_requested = False + state.status = "reconnecting" + if status_line is not None: + status_line.render(state) + await client.stop() + client = make_client(current_phase) + await client.connect() + # Fade the new take in to avoid a hard join + fade_in_samples_remaining = int(fade_duration * sample_rate) except asyncio.CancelledError: pass @@ -481,6 +635,11 @@ async def _run_session( traceback.print_exc() finally: + # Restore the terminal before any further output + if keyboard is not None: + keyboard.stop() + if status_line is not None: + status_line.finish() # Flush any remaining buffered audio to file (for Ctrl+C case with duration set) if fade_out_buffer and file_output: combined = np.concatenate(fade_out_buffer, axis=0) diff --git a/src/focus/ui/__init__.py b/src/focus/ui/__init__.py new file mode 100644 index 0000000..8f6e812 --- /dev/null +++ b/src/focus/ui/__init__.py @@ -0,0 +1,5 @@ +"""Interactive terminal UI for the focus player. + +Contains the shared playback-state object, the single-key transport controls, +the live status line, and the bare-``focus`` profile launcher. +""" diff --git a/src/focus/ui/launcher.py b/src/focus/ui/launcher.py new file mode 100644 index 0000000..7b4445d --- /dev/null +++ b/src/focus/ui/launcher.py @@ -0,0 +1,92 @@ +"""Interactive profile picker shown when ``focus`` is run bare in a terminal. + +Makes the tool usable with zero knowledge of profile names or flags: arrow keys +or number keys to choose, Enter to start, ``q``/Esc to cancel. Power-user flags +remain available via ``focus start ...``. +""" + +import os +import sys + +import click + +from focus.profiles import FocusProfile, list_profiles + + +def _getch(fd: int | None = None) -> bytes: + """Read one keypress (or escape sequence) in cbreak mode. + + Reads the raw fd directly (not a buffered reader): under cbreak with VMIN=1 + a single ``os.read`` returns a whole escape burst (e.g. ``\\x1b[A`` for an + arrow key) in one call, so arrow keys are not mistaken for a bare Escape. + """ + import termios + import tty + + fd = sys.stdin.fileno() if fd is None else fd + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + return os.read(fd, 6) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +def _menu_lines(profiles: list[FocusProfile], selected: int) -> list[str]: + """Build the menu as a list of single (unwrapped) lines. + + Returning exact lines lets the redraw move the cursor up by a precise count; + no embedded newlines means ``len(lines)`` is the true rendered height. + """ + lines = ["", "🎧 " + click.style("Choose a focus profile", bold=True), ""] + for i, p in enumerate(profiles): + marker = click.style("❯", fg="cyan", bold=True) if i == selected else " " + name = click.style(p.name, fg="cyan", bold=(i == selected)) + lines.append(f" {marker} {i + 1}. {name}") + lines.append(f" {p.description}") + lines.append(f" {p.modulation_freq:.0f} Hz @ {p.modulation_depth:.0%}") + lines.append("") + lines.append(" ↑↓ move · 1-9 jump · Enter start · q cancel") + return lines + + +def run_launcher() -> str | None: + """Show the picker and return the chosen profile name, or None if cancelled. + + Caller is responsible for ensuring stdin is a TTY. + """ + profiles = list_profiles() + if not profiles: + return None + + selected = 0 + prev_lines = 0 + out = sys.stdout + try: + while True: + lines = _menu_lines(profiles, selected) + if prev_lines: + # Return to the top of the previous block and clear everything + # below it, so nothing from the prior frame can ghost through. + out.write(f"\x1b[{prev_lines}A") + out.write("\x1b[J") + out.write("\n".join(lines) + "\n") + out.flush() + prev_lines = len(lines) + + key = _getch() + if key in (b"\r", b"\n"): + return profiles[selected].name + if key in (b"q", b"Q", b"\x1b"): + return None + if key == b"\x1b[A": # up + selected = (selected - 1) % len(profiles) + elif key == b"\x1b[B": # down + selected = (selected + 1) % len(profiles) + elif key.isdigit(): + idx = int(key) - 1 + if 0 <= idx < len(profiles): + selected = idx + finally: + out.write("\n") + out.flush() diff --git a/src/focus/ui/transport.py b/src/focus/ui/transport.py new file mode 100644 index 0000000..aa5f6ac --- /dev/null +++ b/src/focus/ui/transport.py @@ -0,0 +1,195 @@ +"""Single-key transport controls and a live status line. + +These sit on top of one shared :class:`PlaybackState`. The audio loop reads the +flags each iteration; the keyboard reader mutates them. Keeping the input source +(raw stdin here) decoupled from the state means other frontends (e.g. OS media +keys) could drive the same object later without touching the audio loop. + +Unix only (macOS/Linux/WSL): uses ``termios``/``tty`` raw input and the asyncio +reader on stdin. Callers must gate construction on ``sys.stdin.isatty()``. +""" + +import asyncio +import os +import shutil +import sys +from collections.abc import Callable +from dataclasses import dataclass + +VOLUME_STEP = 0.1 + + +@dataclass +class PlaybackState: + """Shared, mutable state between the keyboard reader and the audio loop.""" + + # Control flags (set by the keyboard reader, consumed by the audio loop) + paused: bool = False + skip_requested: bool = False + quit_requested: bool = False + show_help: bool = False + volume: float = 1.0 + + # Display fields (set by the audio loop, read by the status line) + profile_name: str = "" + modulation_freq: float = 0.0 + modulation_depth: float = 0.0 + elapsed_seconds: float = 0.0 + buffer_seconds: float = 0.0 + status: str = "connecting" # connecting | playing | paused | reconnecting + + def toggle_pause(self) -> None: + self.paused = not self.paused + + def volume_up(self) -> None: + self.volume = min(1.0, round(self.volume + VOLUME_STEP, 2)) + + def volume_down(self) -> None: + self.volume = max(0.0, round(self.volume - VOLUME_STEP, 2)) + + def handle_key(self, data: bytes) -> None: + """Apply a key (or escape sequence) read from the terminal.""" + if data in (b" ", b"p", b"P"): + self.toggle_pause() + elif data in (b"n", b"N"): + self.skip_requested = True + elif data in (b"q", b"Q"): + self.quit_requested = True + elif data == b"?": + self.show_help = not self.show_help + elif data in (b"+", b"=", b"\x1b[A"): # '=' is unshifted '+'; \x1b[A is up arrow + self.volume_up() + elif data in (b"-", b"_", b"\x1b[B"): # \x1b[B is down arrow + self.volume_down() + + +class KeyboardController: + """Reads single keys from stdin (cbreak mode) and mutates a PlaybackState. + + Uses ``tty.setcbreak`` rather than raw mode so ``Ctrl+C`` still raises + ``KeyboardInterrupt`` and acts as a hard stop. The terminal is always + restored in :meth:`stop`, including on error. + """ + + def __init__( + self, + state: PlaybackState, + loop: asyncio.AbstractEventLoop | None = None, + fd: int | None = None, + on_change: Callable[[], None] | None = None, + ): + self.state = state + self._loop = loop + self._fd = sys.stdin.fileno() if fd is None else fd + self._old_settings = None + self._active = False + # Called after each keypress mutates the state, so the UI updates + # immediately rather than waiting for the next audio chunk. + self._on_change = on_change + + def start(self) -> None: + import termios + import tty + + self._loop = self._loop or asyncio.get_running_loop() + self._old_settings = termios.tcgetattr(self._fd) + # Mark active and save settings *before* the risky add_reader so that + # stop() always restores the terminal even if registration fails. + self._active = True + tty.setcbreak(self._fd) + self._loop.add_reader(self._fd, self._on_readable) + + def _on_readable(self) -> None: + try: + data = os.read(self._fd, 6) + except (OSError, BlockingIOError): + return + if data: + self.state.handle_key(data) + if self._on_change is not None: + self._on_change() + + def stop(self) -> None: + import termios + + if not self._active: + return + self._active = False + if self._loop is not None: + try: + self._loop.remove_reader(self._fd) + except Exception: + pass + if self._old_settings is not None: + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_settings) + self._old_settings = None + + +def _format_time(seconds: float) -> str: + total = int(seconds) + return f"{total // 60:02d}:{total % 60:02d}" + + +_STATUS_TOKENS = { + "connecting": "… connecting", + "playing": "▸ playing", + "paused": "⏸ paused", + "reconnecting": "… reconnecting", +} + + +class StatusLine: + """A single-line, in-place status display with inline key hints.""" + + def __init__(self, stream=None): + self.stream = stream or sys.stdout + self._active = False + + def start(self) -> None: + self._active = True + + def render(self, state: PlaybackState) -> None: + if not self._active: + return + line = self._format(state) + # Truncate to one less than the terminal width (cosmetic right-edge + # cleanup). This counts code points, not display columns, so glyphs that + # render 2 wide (♫, ⏸, ↑↓ on some terminals) can still overflow — hence + # the autowrap guard below is what actually keeps us on one row. + width = shutil.get_terminal_size(fallback=(80, 24)).columns + if len(line) > width - 1: + line = line[: width - 1] + # Disable the terminal's auto-wrap (DECAWM) for the repaint: an over-long + # line then clamps at the last column instead of wrapping onto a second + # physical row. Without this, the cursor lands on the wrapped row and the + # next "\r\x1b[2K" clears only that row, leaving a trail of stale lines. + # Re-enable autowrap immediately after. + self.stream.write("\x1b[?7l\r\x1b[2K" + line + "\x1b[?7h") + self.stream.flush() + + def finish(self) -> None: + """Move off the status line so following output starts cleanly.""" + if self._active: + # Restore autowrap (in case the last render left it disabled) and + # clear the status row so following output starts cleanly. + self.stream.write("\r\x1b[2K\x1b[?7h") + self.stream.flush() + self._active = False + + @staticmethod + def _format(s: PlaybackState) -> str: + token = _STATUS_TOKENS.get(s.status, s.status) + vol = f"{int(round(s.volume * 100))}%" + info = ( + f"♫ {s.profile_name} · {s.modulation_freq:.0f}Hz @ {s.modulation_depth:.0%}" + f" {token} {_format_time(s.elapsed_seconds)}" + f" vol {vol} buf {s.buffer_seconds:.1f}s" + ) + if s.show_help: + hints = ( + "[space/p] pause [n] next take [↑↓ or +/-] volume " + "[?] hide help [q] quit [Ctrl+C] stop" + ) + else: + hints = "[space] pause [n] next [↑↓] volume [?] help [q] quit" + return f"{info} · {hints}" diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..14aa2cd --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,259 @@ +"""Tests for the interactive transport controls and launcher gating.""" + +import asyncio +import io +import os +import time + +try: + import pty +except ImportError: # pragma: no cover - Windows + pty = None + +import pytest +from click.testing import CliRunner + +from focus.audio.output import AudioOutput, MockAudioOutput +from focus.cli import main +from focus.ui import launcher +from focus.ui.transport import KeyboardController, PlaybackState, StatusLine, _format_time + +# The pty-backed tests exercise the raw terminal readers; pty is Unix-only. +requires_pty = pytest.mark.skipif( + not hasattr(os, "openpty"), reason="pty is unavailable on this platform" +) + + +class TestPlaybackState: + def test_space_and_p_toggle_pause(self): + s = PlaybackState() + s.handle_key(b" ") + assert s.paused is True + s.handle_key(b"p") + assert s.paused is False + + def test_n_requests_skip(self): + s = PlaybackState() + s.handle_key(b"n") + assert s.skip_requested is True + + def test_q_requests_quit(self): + s = PlaybackState() + s.handle_key(b"q") + assert s.quit_requested is True + + def test_question_toggles_help(self): + s = PlaybackState() + s.handle_key(b"?") + assert s.show_help is True + s.handle_key(b"?") + assert s.show_help is False + + def test_volume_keys_and_arrows(self): + s = PlaybackState(volume=0.5) + s.handle_key(b"+") + assert s.volume == 0.6 + s.handle_key(b"-") + assert s.volume == 0.5 + s.handle_key(b"\x1b[A") # up arrow + assert s.volume == 0.6 + s.handle_key(b"\x1b[B") # down arrow + assert s.volume == 0.5 + + def test_volume_clamped_to_unit_range(self): + s = PlaybackState(volume=1.0) + s.handle_key(b"+") + assert s.volume == 1.0 + s = PlaybackState(volume=0.0) + s.handle_key(b"-") + assert s.volume == 0.0 + + def test_unknown_key_is_ignored(self): + s = PlaybackState() + s.handle_key(b"z") + assert s == PlaybackState() + + +class TestStatusLine: + def test_format_time(self): + assert _format_time(0) == "00:00" + assert _format_time(74) == "01:14" + + def test_format_includes_profile_and_hints(self): + s = PlaybackState(profile_name="deep-work", modulation_freq=18.0, status="playing") + line = StatusLine._format(s) + assert "deep-work" in line + assert "18Hz" in line + assert "[q] quit" in line + + def test_help_toggle_changes_hints(self): + s = PlaybackState(show_help=True) + assert "next take" in StatusLine._format(s) + + def test_render_truncates_to_terminal_width(self, monkeypatch): + # The rendered payload must stay under the terminal width so writing it + # never triggers auto-wrap (which would spawn a new line per update). + monkeypatch.setenv("COLUMNS", "40") + s = PlaybackState(profile_name="adhd-support", modulation_freq=15.0, status="playing") + buf = io.StringIO() + line = StatusLine(stream=buf) + line.start() + line.render(s) + payload = ( + buf.getvalue() + .replace("\x1b[?7l", "") + .replace("\x1b[?7h", "") + .replace("\r", "") + .replace("\x1b[2K", "") + ) + assert len(payload) <= 39 + + def test_render_brackets_repaint_with_autowrap_toggle(self): + # The repaint must disable autowrap (DECAWM) and re-enable it, so an + # over-long line clamps at the last column instead of wrapping onto a + # second physical row (which would leave a trail of stale status lines). + s = PlaybackState(profile_name="deep-work", status="playing") + buf = io.StringIO() + line = StatusLine(stream=buf) + line.start() + line.render(s) + out = buf.getvalue() + assert out.startswith("\x1b[?7l") # autowrap off before the repaint + assert out.endswith("\x1b[?7h") # autowrap restored after + + def test_finish_restores_autowrap(self): + buf = io.StringIO() + line = StatusLine(stream=buf) + line.start() + line.finish() + assert "\x1b[?7h" in buf.getvalue() + + +@requires_pty +class TestKeyboardOnChange: + def test_on_change_fires_after_keypress(self): + master, slave = pty.openpty() + state = PlaybackState(volume=0.5) + calls = [] + + async def run(): + kc = KeyboardController(state, fd=slave, on_change=lambda: calls.append(state.volume)) + kc.start() + try: + os.write(master, b"+") # volume up + await asyncio.sleep(0.1) + finally: + kc.stop() + + try: + asyncio.run(run()) + finally: + os.close(master) + os.close(slave) + + assert calls == [0.6] # 0.5 + one step, reported instantly via on_change + + +class TestMockAudioOutputControls: + def test_set_volume_clamps(self): + out = MockAudioOutput() + out.set_volume(2.0) + assert out.volume == 1.0 + out.set_volume(-1.0) + assert out.volume == 0.0 + + def test_pause_resume(self): + out = MockAudioOutput() + out.pause() + assert out._paused is True + out.resume() + assert out._paused is False + + +class TestAudioOutputControls: + def test_set_volume_clamps(self): + out = AudioOutput() + out.set_volume(2.0) + assert out.volume == 1.0 + out.set_volume(-1.0) + assert out.volume == 0.0 + + def test_pause_blocks_stream_start(self): + # While paused, the stream must not (re)start even if buffer fills. + out = AudioOutput() + out.start() + out.pause() + out._maybe_start_stream() + assert out._stream_active is False + + +@requires_pty +class TestTerminalReaders: + """Exercise the real os.read paths over a pty (otherwise never run headless). + + ``_getch`` flushes pending input when it enters cbreak mode (so stray + keystrokes typed before a prompt are dropped), so the test must write the + key only *after* the reader is blocked in ``read`` — hence the helper thread. + """ + + def _getch_with_input(self, data: bytes) -> bytes: + import threading + + master, slave = pty.openpty() + result = {} + + def reader(): + result["key"] = launcher._getch(fd=slave) + + t = threading.Thread(target=reader) + t.start() + try: + time.sleep(0.1) # let _getch flush and block in read() + os.write(master, data) + t.join(timeout=2.0) + finally: + os.close(master) + os.close(slave) + assert not t.is_alive(), "_getch did not return" + return result["key"] + + def test_getch_reads_full_escape_sequence(self): + assert self._getch_with_input(b"\x1b[A") == b"\x1b[A" # up arrow + + def test_getch_reads_single_key(self): + assert self._getch_with_input(b"q") == b"q" + + def test_keyboard_controller_dispatches_keys(self): + master, slave = pty.openpty() + state = PlaybackState() + + async def run(): + kc = KeyboardController(state, fd=slave) + kc.start() # flushes pending input here + try: + os.write(master, b" ") # pause — written after the flush + await asyncio.sleep(0.1) + finally: + kc.stop() + + try: + asyncio.run(run()) + finally: + os.close(master) + os.close(slave) + + assert state.paused is True + + +class TestLauncherGating: + def test_bare_invocation_without_tty_shows_help(self): + # CliRunner provides a non-TTY stdin/stdout, so the bare invocation must + # fall back to help text rather than entering the interactive launcher. + result = CliRunner().invoke(main, []) + assert result.exit_code == 0 + assert "Usage:" in result.output + + def test_profiles_subcommand_still_works(self): + result = CliRunner().invoke(main, ["profiles"]) + assert result.exit_code == 0 + assert "deep-work" in result.output From bbb622f05fc8a99d1354200338ea7f55d9a37185 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:06:14 +0000 Subject: [PATCH 2/6] Handle missing PortAudio in CI --- src/focus/audio/output.py | 3 ++- src/focus/cli.py | 4 +++- tests/test_ui.py | 8 +++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/focus/audio/output.py b/src/focus/audio/output.py index 3f65c6e..5f409a9 100644 --- a/src/focus/audio/output.py +++ b/src/focus/audio/output.py @@ -15,7 +15,8 @@ import sounddevice as sd SOUNDDEVICE_AVAILABLE = True -except ImportError: +except (ImportError, OSError): + sd = None SOUNDDEVICE_AVAILABLE = False diff --git a/src/focus/cli.py b/src/focus/cli.py index 8cde2c4..737d4e4 100644 --- a/src/focus/cli.py +++ b/src/focus/cli.py @@ -18,7 +18,9 @@ import sounddevice as sd AUDIO_AVAILABLE = True -except ImportError: +except (ImportError, OSError): + np = None + sd = None AUDIO_AVAILABLE = False diff --git a/tests/test_ui.py b/tests/test_ui.py index 14aa2cd..9514c76 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -13,7 +13,7 @@ import pytest from click.testing import CliRunner -from focus.audio.output import AudioOutput, MockAudioOutput +from focus.audio.output import SOUNDDEVICE_AVAILABLE, AudioOutput, MockAudioOutput from focus.cli import main from focus.ui import launcher from focus.ui.transport import KeyboardController, PlaybackState, StatusLine, _format_time @@ -171,6 +171,9 @@ def test_pause_resume(self): class TestAudioOutputControls: + @pytest.mark.skipif( + not SOUNDDEVICE_AVAILABLE, reason="sounddevice/PortAudio is unavailable in this environment" + ) def test_set_volume_clamps(self): out = AudioOutput() out.set_volume(2.0) @@ -178,6 +181,9 @@ def test_set_volume_clamps(self): out.set_volume(-1.0) assert out.volume == 0.0 + @pytest.mark.skipif( + not SOUNDDEVICE_AVAILABLE, reason="sounddevice/PortAudio is unavailable in this environment" + ) def test_pause_blocks_stream_start(self): # While paused, the stream must not (re)start even if buffer fills. out = AudioOutput() From d0eea732af98871502c33875407541e2b8010180 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:48:05 +0000 Subject: [PATCH 3/6] Address review feedback for terminal controls --- src/focus/audio/output.py | 8 ++-- src/focus/cli.py | 8 +++- src/focus/ui/launcher.py | 28 +++++++++++--- src/focus/ui/transport.py | 14 ++++++- tests/test_ui.py | 79 ++++++++++++++++++++++++++++++++++----- 5 files changed, 116 insertions(+), 21 deletions(-) diff --git a/src/focus/audio/output.py b/src/focus/audio/output.py index 5f409a9..81d5877 100644 --- a/src/focus/audio/output.py +++ b/src/focus/audio/output.py @@ -71,6 +71,7 @@ def __post_init__(self): def start(self) -> None: """Prepare for audio output (stream starts when buffer is filled).""" self._running = True + self._paused = False self._stream_active = False self._in_underrun = False self._underrun_fade_pos = 0 @@ -251,7 +252,7 @@ def _drain_queue(self) -> None: """Discard all buffered blocks (used when pausing).""" if self._queue is None: return - while not self._queue.empty(): + while True: try: self._queue.get_nowait() except queue.Empty: @@ -262,7 +263,7 @@ def pause(self) -> None: The object stays alive; the next ``write()`` calls re-fill the buffer and ``_maybe_start_stream`` recreates the stream once enough is buffered (so - resume is click-free, just like initial start). Call ``resume()`` first. + resume is click-free, just like initial start). Call ``resume()`` to restart. """ self._paused = True if self._stream: @@ -317,10 +318,11 @@ def __init__(self, sample_rate: int = 48000, channels: int = 2): def start(self) -> None: self._running = True + self._paused = False self.written_samples = 0 def write(self, audio: np.ndarray) -> None: - if self._running: + if self._running and not self._paused: self.written_samples += len(audio) def flush(self) -> None: diff --git a/src/focus/cli.py b/src/focus/cli.py index 737d4e4..42b792d 100644 --- a/src/focus/cli.py +++ b/src/focus/cli.py @@ -376,7 +376,13 @@ def _on_key(): status_line.render(state) keyboard = KeyboardController(state, on_change=_on_key) - keyboard.start() + try: + keyboard.start() + except Exception: + keyboard.stop() + if status_line is not None: + status_line.finish() + raise # Initialize DSP state mod_state = ModulationState() diff --git a/src/focus/ui/launcher.py b/src/focus/ui/launcher.py index 7b4445d..e848159 100644 --- a/src/focus/ui/launcher.py +++ b/src/focus/ui/launcher.py @@ -6,6 +6,7 @@ """ import os +import select import sys import click @@ -16,9 +17,10 @@ def _getch(fd: int | None = None) -> bytes: """Read one keypress (or escape sequence) in cbreak mode. - Reads the raw fd directly (not a buffered reader): under cbreak with VMIN=1 - a single ``os.read`` returns a whole escape burst (e.g. ``\\x1b[A`` for an - arrow key) in one call, so arrow keys are not mistaken for a bare Escape. + Reads the raw fd directly (not a buffered reader). Normal keys are read one + byte at a time so already-buffered keystrokes are not coalesced. For Escape, + briefly collect the rest of a CSI arrow-key sequence so arrow keys are not + mistaken for a bare Escape. """ import termios import tty @@ -27,7 +29,21 @@ def _getch(fd: int | None = None) -> bytes: old = termios.tcgetattr(fd) try: tty.setcbreak(fd) - return os.read(fd, 6) + key = os.read(fd, 1) + if key != b"\x1b": + return key + + readable, _, _ = select.select([fd], [], [], 0.01) + if not readable: + return key + prefix = os.read(fd, 1) + if prefix != b"[": + return key + + readable, _, _ = select.select([fd], [], [], 0.01) + if not readable: + return key + prefix + return key + prefix + os.read(fd, 1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) @@ -65,12 +81,14 @@ def run_launcher() -> str | None: try: while True: lines = _menu_lines(profiles, selected) + out.write("\x1b[?7l") if prev_lines: # Return to the top of the previous block and clear everything # below it, so nothing from the prior frame can ghost through. out.write(f"\x1b[{prev_lines}A") out.write("\x1b[J") out.write("\n".join(lines) + "\n") + out.write("\x1b[?7h") out.flush() prev_lines = len(lines) @@ -88,5 +106,5 @@ def run_launcher() -> str | None: if 0 <= idx < len(profiles): selected = idx finally: - out.write("\n") + out.write("\x1b[?7h\n") out.flush() diff --git a/src/focus/ui/transport.py b/src/focus/ui/transport.py index aa5f6ac..c544fe9 100644 --- a/src/focus/ui/transport.py +++ b/src/focus/ui/transport.py @@ -19,6 +19,16 @@ VOLUME_STEP = 0.1 +def _iter_key_events(data: bytes): + while data: + if data.startswith((b"\x1b[A", b"\x1b[B")): + yield data[:3] + data = data[3:] + else: + yield data[:1] + data = data[1:] + + @dataclass class PlaybackState: """Shared, mutable state between the keyboard reader and the audio loop.""" @@ -104,8 +114,8 @@ def _on_readable(self) -> None: data = os.read(self._fd, 6) except (OSError, BlockingIOError): return - if data: - self.state.handle_key(data) + for key in _iter_key_events(data): + self.state.handle_key(key) if self._on_change is not None: self._on_change() diff --git a/tests/test_ui.py b/tests/test_ui.py index 9514c76..f7edddc 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -10,11 +10,13 @@ except ImportError: # pragma: no cover - Windows pty = None +import numpy as np import pytest from click.testing import CliRunner from focus.audio.output import SOUNDDEVICE_AVAILABLE, AudioOutput, MockAudioOutput from focus.cli import main +from focus.profiles import FocusProfile from focus.ui import launcher from focus.ui.transport import KeyboardController, PlaybackState, StatusLine, _format_time @@ -22,6 +24,9 @@ requires_pty = pytest.mark.skipif( not hasattr(os, "openpty"), reason="pty is unavailable on this platform" ) +requires_sounddevice = pytest.mark.skipif( + not SOUNDDEVICE_AVAILABLE, reason="sounddevice/PortAudio is unavailable in this environment" +) class TestPlaybackState: @@ -153,6 +158,29 @@ async def run(): assert calls == [0.6] # 0.5 + one step, reported instantly via on_change + def test_buffered_keypresses_are_dispatched_individually(self): + master, slave = pty.openpty() + state = PlaybackState(volume=0.5) + calls = [] + + async def run(): + kc = KeyboardController(state, fd=slave, on_change=lambda: calls.append(state.volume)) + kc.start() + try: + os.write(master, b"++") + await asyncio.sleep(0.1) + finally: + kc.stop() + + try: + asyncio.run(run()) + finally: + os.close(master) + os.close(slave) + + assert state.volume == 0.7 + assert calls == [0.6, 0.7] + class TestMockAudioOutputControls: def test_set_volume_clamps(self): @@ -169,11 +197,16 @@ def test_pause_resume(self): out.resume() assert out._paused is False + def test_paused_write_is_ignored(self): + out = MockAudioOutput() + out.start() + out.pause() + out.write(np.zeros(10)) + assert out.written_samples == 0 + +@requires_sounddevice class TestAudioOutputControls: - @pytest.mark.skipif( - not SOUNDDEVICE_AVAILABLE, reason="sounddevice/PortAudio is unavailable in this environment" - ) def test_set_volume_clamps(self): out = AudioOutput() out.set_volume(2.0) @@ -181,9 +214,6 @@ def test_set_volume_clamps(self): out.set_volume(-1.0) assert out.volume == 0.0 - @pytest.mark.skipif( - not SOUNDDEVICE_AVAILABLE, reason="sounddevice/PortAudio is unavailable in this environment" - ) def test_pause_blocks_stream_start(self): # While paused, the stream must not (re)start even if buffer fills. out = AudioOutput() @@ -197,9 +227,8 @@ def test_pause_blocks_stream_start(self): class TestTerminalReaders: """Exercise the real os.read paths over a pty (otherwise never run headless). - ``_getch`` flushes pending input when it enters cbreak mode (so stray - keystrokes typed before a prompt are dropped), so the test must write the - key only *after* the reader is blocked in ``read`` — hence the helper thread. + The tests write the key only *after* the reader is blocked in ``read`` so + each case exercises the cbreak-mode read path deterministically. """ def _getch_with_input(self, data: bytes) -> bytes: @@ -214,7 +243,7 @@ def reader(): t = threading.Thread(target=reader) t.start() try: - time.sleep(0.1) # let _getch flush and block in read() + time.sleep(0.1) # let _getch enter cbreak mode and block in read() os.write(master, data) t.join(timeout=2.0) finally: @@ -229,6 +258,15 @@ def test_getch_reads_full_escape_sequence(self): def test_getch_reads_single_key(self): assert self._getch_with_input(b"q") == b"q" + def test_getch_reads_one_buffered_keypress(self): + assert self._getch_with_input(b"qq") == b"q" + + def test_getch_treats_non_csi_escape_as_escape(self): + assert self._getch_with_input(b"\x1bq") == b"\x1b" + + def test_getch_keeps_escape_sequence_separate_from_following_key(self): + assert self._getch_with_input(b"\x1b[Aq") == b"\x1b[A" + def test_keyboard_controller_dispatches_keys(self): master, slave = pty.openpty() state = PlaybackState() @@ -251,6 +289,27 @@ async def run(): assert state.paused is True +class TestLauncherRedraw: + def test_redraw_brackets_menu_with_autowrap_toggle(self, monkeypatch): + profile = FocusProfile( + name="deep-work", + description="A long description that should not affect redraw line counts", + prompt="prompt", + modulation_freq=18.0, + modulation_depth=0.35, + ) + out = io.StringIO() + monkeypatch.setattr(launcher, "list_profiles", lambda: [profile]) + monkeypatch.setattr(launcher, "_getch", lambda: b"q") + monkeypatch.setattr(launcher.sys, "stdout", out) + + assert launcher.run_launcher() is None + rendered = out.getvalue() + assert rendered.startswith("\x1b[?7l") + assert "\x1b[?7h" in rendered + assert rendered.endswith("\x1b[?7h\n") + + class TestLauncherGating: def test_bare_invocation_without_tty_shows_help(self): # CliRunner provides a non-TTY stdin/stdout, so the bare invocation must From fdb9adf2479d8c5502de039c13dd694f4ea2c2f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:49:03 +0000 Subject: [PATCH 4/6] Harden keyboard reader startup cleanup --- src/focus/ui/transport.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/focus/ui/transport.py b/src/focus/ui/transport.py index c544fe9..abea03a 100644 --- a/src/focus/ui/transport.py +++ b/src/focus/ui/transport.py @@ -17,6 +17,7 @@ from dataclasses import dataclass VOLUME_STEP = 0.1 +MAX_KEY_READ_BYTES = 6 # Enough for the arrow-key escape sequences handled below. def _iter_key_events(data: bytes): @@ -107,11 +108,15 @@ def start(self) -> None: # stop() always restores the terminal even if registration fails. self._active = True tty.setcbreak(self._fd) - self._loop.add_reader(self._fd, self._on_readable) + try: + self._loop.add_reader(self._fd, self._on_readable) + except Exception: + self.stop() + raise def _on_readable(self) -> None: try: - data = os.read(self._fd, 6) + data = os.read(self._fd, MAX_KEY_READ_BYTES) except (OSError, BlockingIOError): return for key in _iter_key_events(data): From f990f59b5e3e284fd4c02cdcb77a83ae6d4ed6c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:49:58 +0000 Subject: [PATCH 5/6] Clarify keyboard read buffer size --- src/focus/ui/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/focus/ui/transport.py b/src/focus/ui/transport.py index abea03a..b3619f5 100644 --- a/src/focus/ui/transport.py +++ b/src/focus/ui/transport.py @@ -17,7 +17,7 @@ from dataclasses import dataclass VOLUME_STEP = 0.1 -MAX_KEY_READ_BYTES = 6 # Enough for the arrow-key escape sequences handled below. +MAX_KEY_READ_BYTES = 3 # Enough for the arrow-key escape sequences handled below. def _iter_key_events(data: bytes): From 2cf0c02fde176298a515ddff12c3d72960f7b308 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:51:01 +0000 Subject: [PATCH 6/6] Tidy review feedback follow-ups --- src/focus/audio/output.py | 3 ++- src/focus/ui/launcher.py | 6 ++++-- src/focus/ui/transport.py | 2 +- tests/test_ui.py | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/focus/audio/output.py b/src/focus/audio/output.py index 81d5877..af059c4 100644 --- a/src/focus/audio/output.py +++ b/src/focus/audio/output.py @@ -263,7 +263,8 @@ def pause(self) -> None: The object stays alive; the next ``write()`` calls re-fill the buffer and ``_maybe_start_stream`` recreates the stream once enough is buffered (so - resume is click-free, just like initial start). Call ``resume()`` to restart. + resume is click-free, just like initial start). Call ``resume()`` to clear + the pause flag; subsequent ``write()`` calls restart the stream lazily. """ self._paused = True if self._stream: diff --git a/src/focus/ui/launcher.py b/src/focus/ui/launcher.py index e848159..ba39c5a 100644 --- a/src/focus/ui/launcher.py +++ b/src/focus/ui/launcher.py @@ -13,6 +13,8 @@ from focus.profiles import FocusProfile, list_profiles +ESCAPE_SEQUENCE_TIMEOUT_SECONDS = 0.01 + def _getch(fd: int | None = None) -> bytes: """Read one keypress (or escape sequence) in cbreak mode. @@ -33,14 +35,14 @@ def _getch(fd: int | None = None) -> bytes: if key != b"\x1b": return key - readable, _, _ = select.select([fd], [], [], 0.01) + readable, _, _ = select.select([fd], [], [], ESCAPE_SEQUENCE_TIMEOUT_SECONDS) if not readable: return key prefix = os.read(fd, 1) if prefix != b"[": return key - readable, _, _ = select.select([fd], [], [], 0.01) + readable, _, _ = select.select([fd], [], [], ESCAPE_SEQUENCE_TIMEOUT_SECONDS) if not readable: return key + prefix return key + prefix + os.read(fd, 1) diff --git a/src/focus/ui/transport.py b/src/focus/ui/transport.py index b3619f5..ff150a0 100644 --- a/src/focus/ui/transport.py +++ b/src/focus/ui/transport.py @@ -17,7 +17,7 @@ from dataclasses import dataclass VOLUME_STEP = 0.1 -MAX_KEY_READ_BYTES = 3 # Enough for the arrow-key escape sequences handled below. +MAX_KEY_READ_BYTES = 3 # Enough for arrow-key escape sequences handled by _iter_key_events. def _iter_key_events(data: bytes): diff --git a/tests/test_ui.py b/tests/test_ui.py index f7edddc..9632a97 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -27,6 +27,7 @@ requires_sounddevice = pytest.mark.skipif( not SOUNDDEVICE_AVAILABLE, reason="sounddevice/PortAudio is unavailable in this environment" ) +READER_STARTUP_DELAY = 0.1 class TestPlaybackState: @@ -111,7 +112,7 @@ def test_render_truncates_to_terminal_width(self, monkeypatch): .replace("\r", "") .replace("\x1b[2K", "") ) - assert len(payload) <= 39 + assert len(payload) <= 39 # production truncates to terminal width minus one def test_render_brackets_repaint_with_autowrap_toggle(self): # The repaint must disable autowrap (DECAWM) and re-enable it, so an @@ -243,7 +244,7 @@ def reader(): t = threading.Thread(target=reader) t.start() try: - time.sleep(0.1) # let _getch enter cbreak mode and block in read() + time.sleep(READER_STARTUP_DELAY) # let _getch enter cbreak mode and block in read() os.write(master, data) t.join(timeout=2.0) finally: