splinter-keep/tools/run.py
Dejvino a2a6b1cb26 add ambience music system: TUI crossfade, yt-dlp fetch, source tracking
- tools/run.py: pygame.mixer subsystem — polls session/ambience.md,
  crossfades tracks, shows ♫ in status bar
- tools/music-fetch.py: search/download from YouTube via yt-dlp,
  auto-increment filenames, --replace and --dry-run modes
- tools/ambience.py: companion CLI to set ambience state
- session/ambience.md: current ambience state file (DM writes here)
- session/ambience_options.md: ambience → file mapping table
- session/ambience_sources.md: file → YouTube URL tracking for re-download
- session/audio/ added to .gitignore (audio files not tracked in git)
2026-06-24 21:44:18 +02:00

416 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
run.py — The Chaos TTRPG Session Client
Layout: banner top | log (main) + character (right) | input bottom.
Music: polls session/ambience.md, crossfades via pygame.mixer.
"""
import os
import random
import sys
from datetime import date
from pathlib import Path
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Input, Static
# ── Optional pygame ───────────────────────────────────────
try:
import pygame
HAS_PYGAME = True
except ImportError:
HAS_PYGAME = False
# ── Paths ────────────────────────────────────────────────
BASE = Path(__file__).resolve().parent.parent
SESSION = BASE / 'session'
LOG_DIR = SESSION / 'log'
CHAR_PATH = SESSION / 'character.md'
WORLD_PATH = SESSION / 'world.md'
JOURNAL_PATH = SESSION / 'journal.md'
AMBIENCE_PATH = SESSION / 'ambience.md'
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
AUDIO_DIR = SESSION / 'audio'
TODAY = date.today().isoformat()
LOG_PATH = LOG_DIR / f'{TODAY}.md'
REFRESH_SECS = 2
# ── Helpers ──────────────────────────────────────────────
def ensure_log():
LOG_DIR.mkdir(parents=True, exist_ok=True)
if not LOG_PATH.exists():
LOG_PATH.write_text(f"# Session Log — {TODAY}\n\n")
def append_log(text):
with open(LOG_PATH, 'a') as f:
f.write(f"- {text}\n")
def read_todo():
if not JOURNAL_PATH.exists():
return ["—— No journal yet ——"]
lines = JOURNAL_PATH.read_text().splitlines()
in_todo = False
todo = []
for l in lines:
if l.strip().lstrip('#').strip().startswith('TODO'):
in_todo = True
continue
if l.strip().startswith('#') and in_todo:
break
if in_todo and l.strip():
todo.append(l.strip().lstrip('- '))
return todo or ["—— All done! ——"]
def read_log_tail(n=200):
if not LOG_PATH.exists():
return []
lines = LOG_PATH.read_text().splitlines()
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
def read_char_sheet():
if not CHAR_PATH.exists():
return ["—— No character yet ——"]
lines = CHAR_PATH.read_text().splitlines()
out = []
for l in lines:
s = l.rstrip()
if s.startswith('**') and ':' in s:
out.append(s.strip('*').strip())
elif s.startswith('- **'):
out.append(s.lstrip('- ').strip('*').strip())
return out or ["—— No character yet ——"]
# ── Status summary ───────────────────────────────────────
def status_summary():
if not CHAR_PATH.exists():
return "no character"
lines = CHAR_PATH.read_text().splitlines()
name = "?"
health = "?"
for l in lines:
if l.startswith('**Name:**'):
name = l.split(':', 1)[1].strip().strip('_').strip('*')
if l.startswith('**Current Health:**'):
h = l.split(':', 1)[1].strip().strip('_').strip('*')
if h:
health = h
if l.startswith('**Max Health:**'):
m = l.split(':', 1)[1].strip().strip('_').strip('*')
if m and health == '?':
health = m
return f"{name}{health}"
# ── Log line count ───────────────────────────────────────
def log_count():
return len(read_log_tail())
# ── Ambience subsystem ───────────────────────────────────
def parse_ambience_options():
"""Parse ambience_options.md into {name: [filepath, ...]}"""
if not AMBIENCE_OPTIONS_PATH.exists():
return {}
options = {}
lines = AMBIENCE_OPTIONS_PATH.read_text().splitlines()
in_table = False
for line in lines:
s = line.strip()
if not s.startswith('|') or not s.endswith('|'):
in_table = False
continue
parts = [p.strip() for p in s.split('|')]
parts = [p for p in parts if p]
if len(parts) < 2:
continue
if not in_table:
in_table = True
continue
if all(c in '-:| ' for c in s):
continue
name = parts[0].lower()
files = [f.strip() for f in parts[1].split(',') if f.strip()]
paths = [AUDIO_DIR / f for f in files]
options[name] = paths
return options
class AmbiencePlayer:
"""Monitors ambience.md and crossfades background music."""
def __init__(self):
self.current_ambience = 'silence'
self._enabled = False
self._last_mtime = 0
self._options = {}
self._init_pygame()
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
def available(self):
return self._enabled
@property
def ambience_name(self):
return self.current_ambience
def load_options(self):
self._options = parse_ambience_options()
def poll(self):
if not self._enabled:
return
try:
mtime = os.path.getmtime(AMBIENCE_PATH)
except OSError:
return
if mtime == self._last_mtime:
return
self._last_mtime = mtime
try:
name = AMBIENCE_PATH.read_text().strip().lower()
except OSError:
return
self._switch_to(name)
def _switch_to(self, name):
if name == self.current_ambience:
return
self.current_ambience = name
if name == 'silence' or name not in self._options:
if pygame.mixer.music.get_busy():
pygame.mixer.music.fadeout(2000)
return
tracks = self._options.get(name, [])
# filter to existing files
valid = [t for t in tracks if t.exists()]
if not valid:
return
track = random.choice(valid)
if pygame.mixer.music.get_busy():
pygame.mixer.music.fadeout(2000)
try:
pygame.mixer.music.load(str(track))
pygame.mixer.music.set_volume(0.4)
pygame.mixer.music.play(loops=-1, fade_ms=2000)
except pygame.error:
self.current_ambience = None
# ── Auto-refreshing panels ───────────────────────────────
class AutoStatic(Static):
"""A Static that reloads its content on an interval."""
def load(self):
raise NotImplementedError
def on_mount(self):
self.load()
self.set_interval(REFRESH_SECS, self.load)
class TodoPane(AutoStatic):
def load(self):
items = read_todo()
self.update("\n".join(f"{i}" for i in items))
class TranscriptPane(AutoStatic):
def load(self):
lines = read_log_tail()
self.update("\n".join(lines[-80:]))
class CharPane(AutoStatic):
def load(self):
lines = read_char_sheet()
self.update("\n".join(f" {l}" for l in lines))
class StatusBar(AutoStatic):
def load(self):
char = status_summary()
count = log_count()
todo = len(read_todo())
music = ""
if app_ambience_player and app_ambience_player.available:
name = app_ambience_player.ambience_name
music = f" │ ♫ {name}"
self.update(f"{char}{count} entries │ {todo} todo │ {TODAY}{music}")
# module-level ref so StatusBar can reach it
app_ambience_player = None
# ── The App ──────────────────────────────────────────────
class ChaosTUI(App):
TITLE = "The Chaos"
CSS = """
Screen {
background: #121212;
}
#banner {
dock: top;
height: 1;
background: #2a2a2a;
color: #e0ad4c;
text-align: center;
}
#input-row {
dock: bottom;
height: 3;
background: #252525;
padding: 0 0;
border-top: solid #3a3a3a;
}
Input {
background: #1e1e1e;
color: #e0e0e0;
border: none;
margin: 1 1;
}
Input:focus {
border: none;
}
#main {
height: 100%;
}
#log-col {
border-right: solid #3a3a3a;
background: #111111;
}
#todo-header {
background: #3a2a1a;
color: #e0b060;
text-style: bold;
padding: 0 1;
height: 1;
}
#todo-content {
background: #1a1510;
color: #d0b080;
padding: 0 1;
height: 5;
max-height: 5;
}
#log-header {
background: #1d2d1d;
color: #7dcd7d;
text-style: bold;
padding: 0 1;
height: 1;
}
#transcript {
padding: 0 1;
color: #c8c8c8;
}
#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 {
background: #222222;
color: #888888;
padding: 0 1;
height: 1;
text-style: italic;
}
"""
BINDINGS = [
("ctrl+c", "quit", "Quit"),
("escape", "quit", "Quit"),
]
def __init__(self, *args, no_music=False, **kwargs):
super().__init__(*args, **kwargs)
global app_ambience_player
if not no_music:
app_ambience_player = AmbiencePlayer()
else:
app_ambience_player = None
def compose(self):
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
with Horizontal(id="main"):
with Vertical(id="log-col"):
yield Static("TODO", id="todo-header")
yield TodoPane(id="todo-content")
yield Static("LOG", id="log-header")
yield TranscriptPane(id="transcript")
with Vertical(id="sidebar"):
yield Static("CHARACTER", id="side-header")
yield CharPane(id="char-content")
yield StatusBar(id="status-bar")
with Horizontal(id="input-row"):
self.input = Input(placeholder=" What do you do? (just type)", id="input")
yield self.input
def on_mount(self):
ensure_log()
self.input.focus()
# start ambience polling
self.set_interval(REFRESH_SECS, self._check_ambience)
def _check_ambience(self):
if app_ambience_player:
app_ambience_player.poll()
@on(Input.Submitted, "#input")
def on_input(self, event: Input.Submitted):
text = event.value.strip()
if text:
append_log(text)
self.input.clear()
self.query_one(TodoPane).load()
self.query_one(TranscriptPane).load()
self.query_one(StatusBar).load()
def main():
import argparse
parser = argparse.ArgumentParser(description="The Chaos TUI")
parser.add_argument('--no-music', action='store_true', help='Disable ambience music')
args = parser.parse_args()
app = ChaosTUI(no_music=args.no_music)
app.run()
if __name__ == '__main__':
main()