Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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..."

Expand Down
70 changes: 67 additions & 3 deletions src/focus/audio/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
import sounddevice as sd

SOUNDDEVICE_AVAILABLE = True
except ImportError:
except (ImportError, OSError):
sd = None
SOUNDDEVICE_AVAILABLE = False


Expand All @@ -36,11 +37,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)
Expand Down Expand Up @@ -68,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
Expand All @@ -77,7 +81,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
Comment on lines 82 to 85

# Check if we have enough buffer
Expand Down Expand Up @@ -143,6 +147,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.

Expand Down Expand Up @@ -230,6 +240,48 @@ 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 True:
try:
self._queue.get_nowait()
except queue.Empty:
break
Comment thread
Copilot marked this conversation as resolved.

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()`` to clear
the pause flag; subsequent ``write()`` calls restart the stream lazily.
"""
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."""
Expand Down Expand Up @@ -260,15 +312,18 @@ 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:
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:
Expand All @@ -277,6 +332,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
Comment on lines +338 to +342

@property
def underrun_count(self) -> int:
return self._underrun_count
Expand Down
Loading
Loading