swap pygame for miniaudio — zero system deps, pip install miniaudio
This commit is contained in:
parent
fdb9d4afc2
commit
b614114286
@ -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.
|
||||||
|
|||||||
112
tools/run.py
112
tools/run.py
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user