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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Single-file Python 3 CLI app (`breathe.py`) that paces resonance breathing for H

- **One file**: `breathe.py`. No modules, no packages, no config files. The line cap exists to preserve the core value of a single-file app: one person can read and understand the entire program in one sitting. The file is currently ~780 lines. The DSL feature (TODO #14) will add significantly more. The cap will need to be revisited — either raised with a new target, or replaced by a different complexity constraint (e.g. max cyclomatic complexity, or splitting into a second file). Decision pending before v2.0.
- **Stdlib only**: Python 3.7+. No pip installs. No third-party imports.
- **macOS & Windows 11**: Uses `/usr/bin/afplay` on macOS and `winsound` on Windows for audio.
- **macOS, Windows 11 & Linux**: Uses `/usr/bin/afplay` on macOS, `winsound` on Windows, and an auto-detected player (`paplay`/`pw-play`/`aplay`/`ffplay`/`cvlc`) on Linux for audio. All platforms fall back to the terminal bell.
- **No curses**: Use direct ANSI escape codes. curses has Mojave edge cases with non-default terminals.
- **No threading**: Use `select.select` (on macOS) or `msvcrt` (on Windows) for non-blocking key polling. No `threading.Thread`, no `curses.getch`.

Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ This app is deliberately constrained. Several common breathing-app features are

## Requirements

- macOS (uses `/usr/bin/afplay` for audio cues) or Windows 11 (uses `winsound`)
- macOS (uses `/usr/bin/afplay`), Windows 11 (uses `winsound`), or Linux (auto-detects a sound player — see [Linux audio](#linux-audio))
- Python 3.7+

## Installation
Expand Down Expand Up @@ -172,6 +172,7 @@ Duration: 1–60 minutes (rounded up to complete breath cycles). Ratio: inhale a
| `--duration MIN` | `-d` | Session length in minutes (1–60) |
| `--ratio IN-EX` | `-r` | Breath ratio, e.g. `5-5` or `4-6` |
| `--no-sound` | `-n` | Disable audio cues |
| `--sound-player` | | Linux audio player command (auto-detected) |
| `--quiet` | `-q` | Suppress startup warnings |
| `--no-log` | | Don't log this session |
| `--log` | | Print log file path and exit |
Expand Down Expand Up @@ -206,6 +207,23 @@ The status indicator shows `●` during breathing, `‖` when paused, and `🔇`

The countdown timer tracks completed breathing time only. If you pause for 30 seconds during a 1-minute session, the session takes ~90 seconds of wall-clock time to complete — the timer doesn't advance while paused.

## Linux audio

Linux has no single standard way to play a sound, so Breathe CLI probes for a player and falls back gracefully:

1. **Player** — the first of `paplay`, `pw-play`, `aplay`, `ffplay`, `cvlc` found on your `PATH`. Override with `--sound-player CMD` or the `BREATHE_SOUND_PLAYER` env var.
2. **Sounds** — the freedesktop theme (`/usr/share/sounds/freedesktop/stereo/message.oga` for inhale, `complete.oga` for exhale). Override with `BREATHE_SOUND_INHALE` / `BREATHE_SOUND_EXHALE` (any file your player accepts).
3. **Fallback** — if no player or sound file is found, Breathe CLI uses the terminal bell, exactly as before.

```bash
# Use a specific player and custom cues
BREATHE_SOUND_INHALE=~/sounds/in.wav \
BREATHE_SOUND_EXHALE=~/sounds/out.wav \
breathe --sound-player paplay
```

Most desktop distros ship `paplay` (PulseAudio) or `pw-play` (PipeWire) and the freedesktop sounds, so audio usually works with no configuration.

## Session logging

Each session appends a row to `~/.breathe_log.csv`:
Expand Down
70 changes: 66 additions & 4 deletions breathe.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@
WIN_SOUND_INHALE = os.path.join(os.environ.get('SystemRoot', 'C:\\Windows'), 'Media', 'ding.wav')
WIN_SOUND_EXHALE = os.path.join(os.environ.get('SystemRoot', 'C:\\Windows'), 'Media', 'notify.wav')

# Linux Audio. Players probed in order; the first on PATH wins. Sounds default
# to the freedesktop theme. All overridable via --sound-player and the
# BREATHE_SOUND_PLAYER / BREATHE_SOUND_INHALE / BREATHE_SOUND_EXHALE env vars.
LINUX_PLAYERS = ('paplay', 'pw-play', 'aplay', 'ffplay', 'cvlc')
LINUX_PLAYER_ARGS = {
'ffplay': ('-nodisp', '-autoexit', '-loglevel', 'quiet'),
'cvlc': ('--play-and-exit',),
}
LINUX_SOUND_DIR = '/usr/share/sounds/freedesktop/stereo'
LINUX_SOUND_INHALE = os.path.join(LINUX_SOUND_DIR, 'message.oga')
LINUX_SOUND_EXHALE = os.path.join(LINUX_SOUND_DIR, 'complete.oga')

# Resolved (player, inhale_path, exhale_path) once check_audio() picks 'linux'.
_LINUX_AUDIO = None


LOG_FILE = os.path.expanduser('~/.breathe_log.csv')
LOG_HEADER = 'date,time,preset,ratio,duration_target_s,duration_actual_s,breaths,completion_pct,status'
Expand Down Expand Up @@ -101,6 +116,7 @@ class Config:
preset_name: str # 'balanced', 'calm', 'extended', or 'custom'
sound_enabled: bool
quiet: bool
sound_player: str = None # Linux player override (--sound-player)

@property
def ratio_str(self):
Expand Down Expand Up @@ -180,20 +196,48 @@ def setup_windows_console():
except Exception:
pass

def check_audio(quiet):
"""Init audio subsystem. Returns 'winsound', 'afplay', or 'bell'."""
def resolve_linux_audio(environ, which, override=None):
"""Resolve (player, inhale_path, exhale_path) for Linux, or None for bell.

Player comes from override (--sound-player), then BREATHE_SOUND_PLAYER, then
the first LINUX_PLAYERS entry on PATH. Sound paths come from
BREATHE_SOUND_INHALE / BREATHE_SOUND_EXHALE, else the freedesktop defaults.
Returns None (caller falls back to bell) if no player or sound file is found.
"""
player = override or environ.get('BREATHE_SOUND_PLAYER')
if player:
if not which(player):
return None
else:
player = next((p for p in LINUX_PLAYERS if which(p)), None)
if not player:
return None
inhale = environ.get('BREATHE_SOUND_INHALE', LINUX_SOUND_INHALE)
exhale = environ.get('BREATHE_SOUND_EXHALE', LINUX_SOUND_EXHALE)
if not (os.path.isfile(inhale) and os.path.isfile(exhale)):
return None
return (player, inhale, exhale)

def check_audio(quiet, sound_player=None):
"""Init audio subsystem. Returns 'winsound', 'afplay', 'linux', or 'bell'."""
global _LINUX_AUDIO
if os.name == 'nt':
try:
import winsound
if os.path.isfile(WIN_SOUND_INHALE) and os.path.isfile(WIN_SOUND_EXHALE):
return 'winsound'
except ImportError:
pass
else:
elif sys.platform == 'darwin':
if (os.path.isfile(AFPLAY) and os.access(AFPLAY, os.X_OK)
and os.path.isfile(SOUND_INHALE)
and os.path.isfile(SOUND_EXHALE)):
return 'afplay'
else:
resolved = resolve_linux_audio(os.environ, shutil.which, sound_player)
if resolved:
_LINUX_AUDIO = resolved
return 'linux'
if not quiet:
sys.stderr.write('audio unavailable: falling back to terminal bell\n')
return 'bell'
Expand All @@ -216,6 +260,20 @@ def play_sound(phase, audio_mode):
)
except OSError:
pass
elif audio_mode == 'linux':
if _LINUX_AUDIO is None:
return
player, inhale, exhale = _LINUX_AUDIO
path = inhale if phase == INHALE else exhale
extra = LINUX_PLAYER_ARGS.get(os.path.basename(player), ())
try:
subprocess.Popen(
[player, *extra, path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError:
pass
elif audio_mode == 'bell':
sys.stdout.write('\a')
sys.stdout.flush()
Expand Down Expand Up @@ -416,7 +474,8 @@ def run_session(config, result):
return

setup_windows_console()
audio_mode = check_audio(config.quiet) if config.sound_enabled else 'none'
audio_mode = (check_audio(config.quiet, config.sound_player)
if config.sound_enabled else 'none')
layout = compute_layout()
if layout.minimal and not config.quiet:
sys.stderr.write('Warning: terminal narrow, running in minimal mode.\n')
Expand Down Expand Up @@ -666,6 +725,8 @@ def build_parser():
help='Breath ratio as inhale-exhale (e.g. 5-5 or 4-6)')
parser.add_argument('--no-sound', '-n', action='store_true',
help='Disable audio cues')
parser.add_argument('--sound-player', metavar='CMD',
help='Linux audio player command (default: auto-detect)')
parser.add_argument('--quiet', '-q', action='store_true',
help='Suppress startup warnings')
parser.add_argument('--log', action='store_true',
Expand Down Expand Up @@ -750,6 +811,7 @@ def main():
preset_name=preset_name,
sound_enabled=not args.no_sound,
quiet=args.quiet,
sound_player=args.sound_player,
)

result = Result()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [
"Intended Audience :: Healthcare Industry",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows :: Windows 11",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Topic :: Scientific/Engineering :: Medical Science Apps.",
]
Expand Down
59 changes: 59 additions & 0 deletions test_breathe.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import sys
import unittest
from unittest import mock

# Import the module under test
sys.path.insert(0, os.path.dirname(__file__))
Expand Down Expand Up @@ -373,5 +374,63 @@ def test_frame_rate(self):
self.assertAlmostEqual(breathe.FRAME_SLEEP, 1.0 / breathe.FRAME_RATE_HZ)


class TestResolveLinuxAudio(unittest.TestCase):
"""Linux player/sound resolution — pure logic, filesystem mocked."""

def _which(self, *available):
avail = set(available)
return lambda cmd: ('/usr/bin/' + cmd) if cmd in avail else None

def test_probes_in_order(self):
with mock.patch('os.path.isfile', return_value=True):
r = breathe.resolve_linux_audio({}, self._which('aplay', 'paplay'))
self.assertEqual(r[0], 'paplay') # earlier in LINUX_PLAYERS wins

def test_defaults_to_freedesktop_paths(self):
with mock.patch('os.path.isfile', return_value=True):
r = breathe.resolve_linux_audio({}, self._which('paplay'))
self.assertEqual(r, ('paplay',
breathe.LINUX_SOUND_INHALE,
breathe.LINUX_SOUND_EXHALE))

def test_override_takes_precedence(self):
with mock.patch('os.path.isfile', return_value=True):
r = breathe.resolve_linux_audio(
{'BREATHE_SOUND_PLAYER': 'paplay'},
self._which('paplay', 'aplay'),
override='aplay')
self.assertEqual(r[0], 'aplay')

def test_env_player_used_when_no_override(self):
with mock.patch('os.path.isfile', return_value=True):
r = breathe.resolve_linux_audio(
{'BREATHE_SOUND_PLAYER': 'aplay'},
self._which('paplay', 'aplay'))
self.assertEqual(r[0], 'aplay')

def test_env_sound_paths_used(self):
env = {'BREATHE_SOUND_INHALE': '/x/in.wav',
'BREATHE_SOUND_EXHALE': '/x/out.wav'}
with mock.patch('os.path.isfile', return_value=True):
r = breathe.resolve_linux_audio(env, self._which('paplay'))
self.assertEqual(r, ('paplay', '/x/in.wav', '/x/out.wav'))

def test_no_player_returns_none(self):
with mock.patch('os.path.isfile', return_value=True):
r = breathe.resolve_linux_audio({}, self._which())
self.assertIsNone(r)

def test_unknown_override_returns_none(self):
with mock.patch('os.path.isfile', return_value=True):
r = breathe.resolve_linux_audio(
{}, self._which('paplay'), override='nope')
self.assertIsNone(r)

def test_missing_sound_file_returns_none(self):
with mock.patch('os.path.isfile', return_value=False):
r = breathe.resolve_linux_audio({}, self._which('paplay'))
self.assertIsNone(r)


if __name__ == '__main__':
unittest.main()