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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
100 changes: 100 additions & 0 deletions tests/test_sound.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 == []
107 changes: 86 additions & 21 deletions trushell/chronoterm/sound.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,110 @@
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:
"""Play alarm sound compatible with all platforms and terminals."""
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()
sys.stdout.flush()
48 changes: 38 additions & 10 deletions trushell/pyfunny.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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)
return cowsay.trex(joke_text)
Loading