From fb59e10667322cd87401f239a5dde933794c924d Mon Sep 17 00:00:00 2001 From: Kenneth LaCroix <53909268+kenlacroix@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:37:10 -0600 Subject: [PATCH] feat: add Linux sound playback with auto-detected player (#7) Linux has no universal way to play a sound, so probe for a player on PATH (paplay, pw-play, aplay, ffplay, cvlc) and use the freedesktop sound theme by default. Player and sound paths are overridable via --sound-player and the BREATHE_SOUND_PLAYER / BREATHE_SOUND_INHALE / BREATHE_SOUND_EXHALE env vars. Falls back to the terminal bell when no player or sound file is found. Mirrors the existing afplay/winsound structure: non-blocking Popen in play_sound, a new 'linux' branch in check_audio, stdlib-only, single-file. Adds unit tests for the resolution/fallback logic and updates README, CLAUDE.md, and the pyproject Linux classifier. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- README.md | 20 +++++++++++++- breathe.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++--- pyproject.toml | 1 + test_breathe.py | 59 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ef419d..6ebde47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index c65ee85..a076c57 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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`: diff --git a/breathe.py b/breathe.py index dc136fc..955e66f 100755 --- a/breathe.py +++ b/breathe.py @@ -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' @@ -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): @@ -180,8 +196,31 @@ 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 @@ -189,11 +228,16 @@ def check_audio(quiet): 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' @@ -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() @@ -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') @@ -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', @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 9e5b5e2..4bf0b65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.", ] diff --git a/test_breathe.py b/test_breathe.py index 62b85d4..69ee817 100644 --- a/test_breathe.py +++ b/test_breathe.py @@ -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__)) @@ -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()