swap pygame for miniaudio — zero system deps, pip install miniaudio

This commit is contained in:
Dejvino 2026-06-24 22:17:47 +02:00
parent fdb9d4afc2
commit b614114286
2 changed files with 53 additions and 63 deletions

View File

@ -9,7 +9,7 @@ When multiple files are listed, one is chosen at random each time the ambience a
## Requirements ## Requirements
```bash ```bash
pip install pygame yt-dlp pip install miniaudio yt-dlp
``` ```
`ffmpeg` must also be installed on your system. `ffmpeg` must also be installed on your system.
@ -79,4 +79,4 @@ The TUI status bar shows the current ambience:
Dillion ❤ 10 │ 42 entries │ 3 todo │ 2026-06-24 │ ♫ tavern Dillion ❤ 10 │ 42 entries │ 3 todo │ 2026-06-24 │ ♫ tavern
``` ```
If pygame is not installed, the music icon is absent and no audio plays. If miniaudio is not installed, the status bar shows a hint and no audio plays.

View File

@ -3,7 +3,7 @@
run.py The Chaos TTRPG Session Client run.py The Chaos TTRPG Session Client
Layout: banner top | log (main) + character (right) | input bottom. Layout: banner top | log (main) + character (right) | input bottom.
Music: polls session/ambience.md, crossfades via pygame.mixer. Music: polls session/ambience.md, plays via miniaudio.
""" """
import os import os
@ -18,12 +18,13 @@ from textual.containers import Horizontal, Vertical
from textual.reactive import reactive from textual.reactive import reactive
from textual.widgets import Input, Static from textual.widgets import Input, Static
# ── Optional pygame ─────────────────────────────────────── # ── Optional miniaudio ────────────────────────────────────
try: try:
import pygame import miniaudio
HAS_PYGAME = True HAS_AUDIO = True
except ImportError: except ImportError:
HAS_PYGAME = False HAS_AUDIO = False
print("Note: miniaudio not installed — no ambience music. Install with: pip install miniaudio", file=sys.stderr)
# ── Paths ──────────────────────────────────────────────── # ── Paths ────────────────────────────────────────────────
@ -144,29 +145,19 @@ def parse_ambience_options():
class AmbiencePlayer: class AmbiencePlayer:
"""Monitors ambience.md and crossfades background music.""" """Monitors ambience.md and plays background music via miniaudio."""
def __init__(self): def __init__(self):
self.current_ambience = 'silence' self.current_ambience = 'silence'
self._enabled = False
self._last_mtime = 0 self._last_mtime = 0
self._options = {} self._options = {}
self._init_pygame() self._device = None
self._stream = None
self.load_options() self.load_options()
def _init_pygame(self):
if not HAS_PYGAME:
return
try:
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=512)
self._enabled = True
except Exception:
self._enabled = False
@property @property
def available(self): def available(self):
return self._enabled return HAS_AUDIO
@property @property
def ambience_name(self): def ambience_name(self):
@ -175,8 +166,17 @@ class AmbiencePlayer:
def load_options(self): def load_options(self):
self._options = parse_ambience_options() self._options = parse_ambience_options()
def _stop(self):
if self._device:
try:
self._device.close()
except Exception:
pass
self._device = None
self._stream = None
def poll(self): def poll(self):
if not self._enabled: if not HAS_AUDIO:
return return
try: try:
mtime = os.path.getmtime(AMBIENCE_PATH) mtime = os.path.getmtime(AMBIENCE_PATH)
@ -195,23 +195,19 @@ class AmbiencePlayer:
if name == self.current_ambience: if name == self.current_ambience:
return return
self.current_ambience = name self.current_ambience = name
self._stop()
if name == 'silence' or name not in self._options: if name == 'silence' or name not in self._options:
if pygame.mixer.music.get_busy():
pygame.mixer.music.fadeout(2000)
return return
tracks = self._options.get(name, []) tracks = self._options.get(name, [])
# filter to existing files
valid = [t for t in tracks if t.exists()] valid = [t for t in tracks if t.exists()]
if not valid: if not valid:
return return
track = random.choice(valid) track = random.choice(valid)
if pygame.mixer.music.get_busy():
pygame.mixer.music.fadeout(2000)
try: try:
pygame.mixer.music.load(str(track)) self._stream = miniaudio.stream_file(str(track))
pygame.mixer.music.set_volume(0.4) self._device = miniaudio.PlaybackDevice()
pygame.mixer.music.play(loops=-1, fade_ms=2000) self._device.start(self._stream)
except pygame.error: except Exception:
self.current_ambience = None self.current_ambience = None
@ -250,7 +246,9 @@ class StatusBar(AutoStatic):
count = log_count() count = log_count()
todo = len(read_todo()) todo = len(read_todo())
music = "" music = ""
if app_ambience_player and app_ambience_player.available: if not HAS_AUDIO:
music = " │ ♫ (install miniaudio)"
elif app_ambience_player:
name = app_ambience_player.ambience_name name = app_ambience_player.ambience_name
music = f" │ ♫ {name}" music = f" │ ♫ {name}"
self.update(f"{char}{count} entries │ {todo} todo │ {TODAY}{music}") self.update(f"{char}{count} entries │ {todo} todo │ {TODAY}{music}")
@ -295,10 +293,6 @@ class ChaosTUI(App):
#main { #main {
height: 100%; height: 100%;
}
#log-col {
border-right: solid #3a3a3a;
background: #111111; background: #111111;
} }
#todo-header { #todo-header {
@ -315,6 +309,20 @@ class ChaosTUI(App):
height: 5; height: 5;
max-height: 5; max-height: 5;
} }
#char-header {
background: #2d2d3a;
color: #b0a0e0;
text-style: bold;
padding: 0 1;
height: 1;
}
#char-content {
background: #1e1e2a;
padding: 0 1;
color: #c0c0c0;
height: 14;
max-height: 14;
}
#log-header { #log-header {
background: #1d2d1d; background: #1d2d1d;
color: #7dcd7d; color: #7dcd7d;
@ -325,23 +333,7 @@ class ChaosTUI(App):
#transcript { #transcript {
padding: 0 1; padding: 0 1;
color: #c8c8c8; color: #c8c8c8;
} height: 1fr;
#sidebar {
width: 36;
min-width: 28;
background: #181818;
}
#side-header {
background: #2d2d3a;
color: #b0a0e0;
text-style: bold;
padding: 0 1;
height: 1;
}
#char-content {
padding: 0 1;
color: #c0c0c0;
} }
#status-bar { #status-bar {
background: #222222; background: #222222;
@ -367,16 +359,14 @@ class ChaosTUI(App):
def compose(self): def compose(self):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Horizontal(id="main"): with Vertical(id="main"):
with Vertical(id="log-col"): yield Static("TODO", id="todo-header")
yield Static("TODO", id="todo-header") yield TodoPane(id="todo-content")
yield TodoPane(id="todo-content") yield Static("CHARACTER", id="char-header")
yield Static("LOG", id="log-header") yield CharPane(id="char-content")
yield TranscriptPane(id="transcript") yield Static("LOG", id="log-header")
with Vertical(id="sidebar"): yield TranscriptPane(id="transcript")
yield Static("CHARACTER", id="side-header") yield StatusBar(id="status-bar")
yield CharPane(id="char-content")
yield StatusBar(id="status-bar")
with Horizontal(id="input-row"): with Horizontal(id="input-row"):
self.input = Input(placeholder=" What do you do? (just type)", id="input") self.input = Input(placeholder=" What do you do? (just type)", id="input")
yield self.input yield self.input