diff --git a/README.md b/README.md index 8221363..886ae28 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,9 @@ pytest tests/ The package metadata is in `pyproject.toml`; the runtime version exported by `trushell.__version__` should be kept in sync with it. +For custom joke sounds, prefer `.mp3` and `.wav` assets for the broadest +cross-platform playback support across the available audio backends. + ## License Apache-2.0. See [LICENSE](LICENSE) for details. diff --git a/tests/test_sound.py b/tests/test_sound.py index 066d50f..d8acdf4 100644 --- a/tests/test_sound.py +++ b/tests/test_sound.py @@ -1,10 +1,12 @@ import subprocess from trushell.chronoterm import sound +from trushell import pyfunny def test_play_alarm_uses_quiet_subprocess(monkeypatch): calls = [] + monkeypatch.setattr(sound.sys, "platform", "linux") def fake_which(name: str) -> str | None: return "/usr/bin/" + name if name == "paplay" else None @@ -26,3 +28,101 @@ def fake_run(cmd, stdout, stderr, check): assert calls[0]["stderr"] == subprocess.DEVNULL assert calls[0]["check"] is False assert calls[0]["cmd"][0] == "paplay" + + +def test_play_sound_uses_requested_sound_file(monkeypatch, tmp_path): + sound_file = tmp_path / "custom-sound.mp3" + sound_file.write_text("not real audio") + calls = [] + + monkeypatch.setattr(pyfunny, "_sound_path", lambda filename: sound_file) + + def fake_play_file(path): + calls.append(path) + return True + + monkeypatch.setattr(sound, "play_audio_file", fake_play_file, raising=False) + monkeypatch.setattr(pyfunny, "play_audio_file", fake_play_file, raising=False) + + pyfunny._play_sound("custom-sound.mp3") + + assert calls == [sound_file] + +def test_play_audio_file_uses_string_path_for_linux_players(monkeypatch, tmp_path): + sound_file = tmp_path / "custom-sound.mp3" + sound_file.write_text("not real audio") + calls = [] + + monkeypatch.setattr(sound.sys, "platform", "linux") + monkeypatch.setattr( + sound.shutil, + "which", + lambda name: "/usr/bin/paplay" if name == "paplay" else None, + ) + + def fake_run(cmd, stdout, stderr, check): + calls.append(cmd) + + class FakeResult: + returncode = 0 + + return FakeResult() + + monkeypatch.setattr(sound.subprocess, "run", fake_run) + + assert sound.play_audio_file(sound_file) is True + assert calls == [["paplay", str(sound_file)]] + + +def test_play_sound_falls_back_when_custom_player_unavailable(monkeypatch, tmp_path): + sound_file = tmp_path / "custom-sound.mp3" + sound_file.write_text("not real audio") + alarm_calls = [] + + monkeypatch.setattr(pyfunny, "_sound_path", lambda filename: sound_file) + monkeypatch.setattr( + pyfunny, + "play_audio_file", + lambda path: (_ for _ in ()).throw( + sound.AudioPlaybackUnavailable("no supported player") + ), + ) + monkeypatch.setattr(pyfunny, "play_alarm", lambda: alarm_calls.append("alarm")) + + pyfunny._play_sound("custom-sound.mp3") + + assert alarm_calls == ["alarm"] + + +def test_play_sound_skips_alarm_after_custom_playback_attempt(monkeypatch, tmp_path): + sound_file = tmp_path / "custom-sound.mp3" + sound_file.write_text("not real audio") + alarm_calls = [] + + monkeypatch.setattr(pyfunny, "_sound_path", lambda filename: sound_file) + monkeypatch.setattr(pyfunny, "play_audio_file", lambda path: False) + monkeypatch.setattr(pyfunny, "play_alarm", lambda: alarm_calls.append("alarm")) + + pyfunny._play_sound("custom-sound.mp3") + + assert alarm_calls == [] + + +def test_play_sound_skips_alarm_after_unexpected_playback_exception( + monkeypatch, tmp_path +): + sound_file = tmp_path / "custom-sound.mp3" + sound_file.write_text("not real audio") + alarm_calls = [] + + monkeypatch.setattr(pyfunny, "_sound_path", lambda filename: sound_file) + + def fake_play_file(path): + raise RuntimeError("player failed after starting playback") + + monkeypatch.setattr(pyfunny, "play_audio_file", fake_play_file) + monkeypatch.setattr(pyfunny, "play_alarm", lambda: alarm_calls.append("alarm")) + + pyfunny._play_sound("custom-sound.mp3") + + assert alarm_calls == [] diff --git a/trushell/chronoterm/sound.py b/trushell/chronoterm/sound.py index 80c6dec..26592c1 100644 --- a/trushell/chronoterm/sound.py +++ b/trushell/chronoterm/sound.py @@ -1,8 +1,80 @@ from __future__ import annotations -import os -import sys + import shutil import subprocess +import sys +from pathlib import Path + + +class AudioPlaybackUnavailable(RuntimeError): + """Raised when the host has no supported way to play a selected asset.""" + + +def _run_quietly(cmd: list[str]) -> bool: + result = subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def _resolve_windows_sound_path(path: Path) -> Path | None: + if path.suffix.lower() == ".wav": + return path + + wav_path = path.with_suffix(".wav") + if wav_path.exists(): + return wav_path + + return None + + +def play_audio_file(path: str | Path) -> bool: + """Play a specific audio asset when a platform player is available. + + Returns False when a player was attempted but did not confirm success. + """ + sound_path = Path(path) + sound_path_str = str(sound_path) + + if sys.platform.startswith("win"): + playable_path = _resolve_windows_sound_path(sound_path) + if playable_path is None: + raise AudioPlaybackUnavailable( + f"Windows playback requires a .wav fallback for {sound_path.name}" + ) + + import winsound + + winsound.PlaySound(str(playable_path), winsound.SND_FILENAME) + return True + + if sys.platform == "darwin": + if not shutil.which("afplay"): + raise AudioPlaybackUnavailable("afplay is unavailable") + return _run_quietly(["afplay", sound_path_str]) + + attempted_player = False + for player in ( + ["paplay", sound_path_str], + ["aplay", sound_path_str], + ["ffplay", "-nodisp", "-autoexit", sound_path_str], + ["mpg123", "-q", sound_path_str], + ["mpg321", "-q", sound_path_str], + ): + if shutil.which(player[0]): + attempted_player = True + if _run_quietly(player): + return True + + if attempted_player: + return False + + raise AudioPlaybackUnavailable( + f"No supported Linux audio player could play {sound_path}" + ) def play_alarm() -> None: @@ -10,36 +82,29 @@ def play_alarm() -> None: try: if sys.platform.startswith("win"): import winsound + winsound.Beep(1200, 400) winsound.Beep(900, 400) - + elif sys.platform == "darwin": - subprocess.run( - ["afplay", "/System/Library/Sounds/Glass.aiff"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - + _run_quietly(["afplay", "/System/Library/Sounds/Glass.aiff"]) + else: # Linux/Unix for cmd in [ - ["paplay", "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga"], + [ + "paplay", + "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", + ], ["aplay", "/usr/share/sounds/alsa/Front_Center.wav"], ["canberra-gtk-play", "--id=alarm-clock-elapsed"], ]: if shutil.which(cmd[0]): - result = subprocess.run( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if result.returncode == 0: + if _run_quietly(cmd): return - + sys.stdout.write("\007" * 3) sys.stdout.flush() - + except Exception: sys.stdout.write("\007") - sys.stdout.flush() \ No newline at end of file + sys.stdout.flush() diff --git a/trushell/pyfunny.py b/trushell/pyfunny.py index 32c0c1d..b9748e2 100644 --- a/trushell/pyfunny.py +++ b/trushell/pyfunny.py @@ -7,7 +7,11 @@ import typer from .chronoterm.state import StateStore -from .chronoterm.sound import play_alarm +from .chronoterm.sound import ( + AudioPlaybackUnavailable, + play_alarm, + play_audio_file, +) DEFAULT_JOKE_CHARACTER = "cow" DEFAULT_JOKE_SOUND = "cow-sound.mp3" @@ -19,24 +23,48 @@ def _sound_path(filename: str) -> Path: def _play_sound(filename: str) -> None: sound_path = _sound_path(filename) - + if not sound_path.exists(): typer.secho(f"Sound file missing: {sound_path}", fg=typer.colors.YELLOW) return - # Note: Your play_alarm() currently plays a system beep/tone. - # If you want it to play specific MP3s, you'd need to update play_alarm - # to accept a file path. For now, this just triggers the alarm sound - # as a notification that a joke is coming. try: - play_alarm() + played_selected_sound = play_audio_file(sound_path) + except AudioPlaybackUnavailable: + typer.secho( + "Unable to play selected sound. Falling back to alarm.", + fg=typer.colors.YELLOW, + ) + try: + play_alarm() + except Exception: + typer.secho( + "Unable to play sound. Continuing without audio.", + fg=typer.colors.YELLOW, + ) + return except Exception: - typer.secho("Unable to play sound. Continuing without audio.", fg=typer.colors.YELLOW) + typer.secho( + "Selected sound playback failed unexpectedly. " + "Skipping fallback to avoid overlapping audio.", + fg=typer.colors.YELLOW, + ) + return + + if not played_selected_sound: + typer.secho( + "Selected sound playback was attempted but failed. " + "Skipping fallback to avoid overlapping audio.", + fg=typer.colors.YELLOW, + ) def _joke_preferences() -> tuple[str, str]: state = StateStore().load() - return state.joke_character or DEFAULT_JOKE_CHARACTER, state.joke_sound or DEFAULT_JOKE_SOUND + return ( + state.joke_character or DEFAULT_JOKE_CHARACTER, + state.joke_sound or DEFAULT_JOKE_SOUND, + ) def _render_joke(character_name: str, text: str) -> str: @@ -56,4 +84,4 @@ def joke() -> str: def joke_trex() -> str: joke_text = pyjokes.get_joke() _play_sound("trex-sound.mp3") - return cowsay.trex(joke_text) \ No newline at end of file + return cowsay.trex(joke_text)