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)
This commit is contained in:
parent
d4a19ef438
commit
a2a6b1cb26
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
session/audio/
|
||||
|
||||
33
AGENTS.md
33
AGENTS.md
@ -9,12 +9,17 @@ the-chaos/
|
||||
├── rules/ # LOCKED — the game itself, do not modify
|
||||
│ ├── deck/ # Card tables (souls, cook, creatures, curiosities)
|
||||
│ └── mechanics.md # Core rules reference
|
||||
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py)
|
||||
└── session/ # UNLOCKED — our campaign
|
||||
├── character.md # Player character sheet
|
||||
├── world.md # Keep & Realm state (NPCs, locations, threads)
|
||||
├── tweaks.md # House rules log
|
||||
└── log/ # Raw session logs by date
|
||||
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py)
|
||||
└── session/ # UNLOCKED — our campaign
|
||||
├── character.md # Player character sheet
|
||||
├── world.md # Keep & Realm state (NPCs, locations, threads)
|
||||
├── journal.md # TODO / DONE task tracking
|
||||
├── tweaks.md # House rules log
|
||||
├── ambience.md # Current ambience (written by DM, read by TUI)
|
||||
├── ambience_options.md # Ambience → track file mapping
|
||||
├── ambience_sources.md # Track source URLs (for re-download)
|
||||
├── audio/ # Music files go here
|
||||
└── log/ # Raw session logs by date
|
||||
```
|
||||
|
||||
## First Steps (Fresh Session)
|
||||
@ -55,11 +60,17 @@ Then begin narrating from where things left off.
|
||||
2. **Ask "what do you do?"** — let the player drive. Never pre-decide outcomes.
|
||||
3. **Draw cards when needed** — use `python3 tools/draw.py <deck> <table>` for random results
|
||||
4. **Player rolls dice physically** — they report results, you narrate outcomes
|
||||
5. **Log everything** — after each meaningful beat, append to `session/log/<today>.md`
|
||||
6. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away
|
||||
7. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`
|
||||
8. **Death is real** — if the PC dies, help the player roll a new character. That's the game.
|
||||
5. **Log before narrating** — After every meaningful beat (conversation, travel, roll, combat round, decision), append the beat to `session/log/<today>.md` **before** describing the next scene. The log comes first, always. Format: `- **time of day** — brief description.` Each beat gets its own line. World changes get `- *World Change:* ...` mixed into the timeline.
|
||||
6. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed.
|
||||
7. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration.
|
||||
8. **Set the ambience** — When the scene's mood changes (arriving in town, entering combat, exploring a dungeon), write the ambience name into `session/ambience.md`:
|
||||
```
|
||||
echo "tavern" > session/ambience.md
|
||||
```
|
||||
The TUI polls this file and crossfades background music. Available names are listed in `session/ambience_options.md`. Use `silence` to stop music.
|
||||
9. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`.
|
||||
10. **Death is real** — if the PC dies, help the player roll a new character. That's the game.
|
||||
|
||||
## The TUI
|
||||
|
||||
The player may have `tools/run.py` open in another terminal. It reads `session/character.md` and the log file to display a live dashboard. Keep those files accurate and it will reflect the game state.
|
||||
The player may have `tools/run.py` open in another terminal. It reads `session/character.md` and the log file to display a live dashboard. Keep those files accurate and it will reflect the game state. The TUI also displays the current ambience in the status bar when music is active.
|
||||
|
||||
1
session/ambience.md
Normal file
1
session/ambience.md
Normal file
@ -0,0 +1 @@
|
||||
silence
|
||||
79
session/ambience_options.md
Normal file
79
session/ambience_options.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Ambience Options
|
||||
|
||||
Set the current ambience by writing its name into `session/ambience.md`.
|
||||
The TUI polls this file and crossfades to the matching track.
|
||||
|
||||
Music files go in `session/audio/`. Supported formats: `.mp3`, `.ogg`, `.wav`.
|
||||
When multiple files are listed, one is chosen at random each time the ambience activates.
|
||||
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
pip install pygame yt-dlp
|
||||
```
|
||||
`ffmpeg` must also be installed on your system.
|
||||
|
||||
## Fetching New Tracks
|
||||
|
||||
Use the music-fetch tool to search YouTube and download tracks:
|
||||
|
||||
```bash
|
||||
# Auto-search for a tavern track
|
||||
python3 tools/music-fetch.py tavern
|
||||
|
||||
# Custom query
|
||||
python3 tools/music-fetch.py "deep fen" "swamp ambience D&D"
|
||||
|
||||
# Specific video
|
||||
python3 tools/music-fetch.py tavern --url "https://youtu.be/..."
|
||||
|
||||
# Replace all tracks for an ambience
|
||||
python3 tools/music-fetch.py tavern --replace
|
||||
|
||||
# Preview without downloading
|
||||
python3 tools/music-fetch.py tavern --dry-run
|
||||
```
|
||||
|
||||
Sources are recorded in `session/ambience_sources.md` so tracks can be
|
||||
re-downloaded without keeping audio files in git.
|
||||
|
||||
## Available Ambiences
|
||||
|
||||
| Ambience | Files |
|
||||
|----------|-------|
|
||||
| silence | (stops all music) |
|
||||
| calm | calm_01.ogg |
|
||||
| combat | combat_01.ogg |
|
||||
| dungeon | dungeon_01.ogg, dungeon_02.ogg |
|
||||
| forest | forest_01.ogg, forest_02.ogg |
|
||||
| tavern | tavern_01.ogg |
|
||||
| tension | tension_01.ogg |
|
||||
| town | town_01.ogg |
|
||||
| wilds | wilds_01.ogg |
|
||||
|
||||
## Usage (DM)
|
||||
|
||||
```bash
|
||||
# Switch to forest ambience
|
||||
echo "forest" > session/ambience.md
|
||||
|
||||
# Stop music
|
||||
echo "silence" > session/ambience.md
|
||||
```
|
||||
|
||||
Or use the companion CLI shortcut:
|
||||
|
||||
```bash
|
||||
python3 tools/ambience.py forest
|
||||
python3 tools/ambience.py silence
|
||||
```
|
||||
|
||||
## Status Display
|
||||
|
||||
The TUI status bar shows the current ambience:
|
||||
|
||||
```
|
||||
Dillion ❤ 10 │ 42 entries │ 3 todo │ 2026-06-24 │ ♫ tavern
|
||||
```
|
||||
|
||||
If pygame is not installed, the music icon is absent and no audio plays.
|
||||
7
session/ambience_sources.md
Normal file
7
session/ambience_sources.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Ambience Sources
|
||||
|
||||
Each entry maps a local audio file to its source URL so tracks can be
|
||||
re-downloaded without keeping the audio files in git.
|
||||
|
||||
| File | Ambience | Source |
|
||||
|------|----------|--------|
|
||||
@ -4,4 +4,6 @@ _Any house rules, custom tables, or modifications we've made._
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| | |
|
||||
| 2026-06-23 | AGENTS.md rule #5 strengthened: "Log before narrating, not after." Log now gets written before the next scene description. |
|
||||
| 2026-06-23 | Created journal.md with TODO/DONE sections. Added TODO pane to run.py TUI (above the log window). |
|
||||
| 2026-06-24 | Added ambience music subsystem to run.py — polls `session/ambience.md`, crossfades via pygame.mixer. Created companion CLI `tools/ambience.py`, config file `session/ambience_options.md`, and `session/audio/` directory. |
|
||||
|
||||
44
tools/ambience.py
Executable file
44
tools/ambience.py
Executable file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Companion CLI to set ambience for the TUI.
|
||||
|
||||
Usage:
|
||||
python3 tools/ambience.py <name>
|
||||
python3 tools/ambience.py silence
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SESSION = Path(__file__).resolve().parent.parent / 'session'
|
||||
AMBIENCE_PATH = SESSION / 'ambience.md'
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
names = _available()
|
||||
print(f"Usage: python3 tools/ambience.py <name>")
|
||||
print(f"Available: {', '.join(names)}")
|
||||
sys.exit(1)
|
||||
|
||||
name = sys.argv[1].lower()
|
||||
AMBIENCE_PATH.write_text(name + '\n')
|
||||
print(f"♫ ambience set to: {name}")
|
||||
|
||||
|
||||
def _available():
|
||||
path = SESSION / 'ambience_options.md'
|
||||
if not path.exists():
|
||||
return ['silence']
|
||||
names = ['silence']
|
||||
for line in path.read_text().splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith('|') and s.endswith('|'):
|
||||
parts = [p.strip() for p in s.split('|')]
|
||||
parts = [p for p in parts if p]
|
||||
if parts and parts[0].lower() not in ('ambience', '---'):
|
||||
names.append(parts[0])
|
||||
return names
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
367
tools/music-fetch.py
Executable file
367
tools/music-fetch.py
Executable file
@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tools/music-fetch.py — Search YouTube and download ambience music.
|
||||
Wires downloaded tracks into session/ambience_options.md.
|
||||
|
||||
Requires:
|
||||
pip install yt-dlp
|
||||
ffmpeg (system install, for audio extraction)
|
||||
|
||||
Usage:
|
||||
python3 tools/music-fetch.py tavern
|
||||
python3 tools/music-fetch.py "deep fen" "swamp ambience D&D"
|
||||
python3 tools/music-fetch.py tavern --url "https://youtu.be/..."
|
||||
python3 tools/music-fetch.py tavern --replace
|
||||
python3 tools/music-fetch.py tavern --dry-run
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
SESSION = BASE / 'session'
|
||||
AUDIO_DIR = SESSION / 'audio'
|
||||
OPTIONS_PATH = SESSION / 'ambience_options.md'
|
||||
SOURCES_PATH = SESSION / 'ambience_sources.md'
|
||||
|
||||
# ── Default search queries per ambience ───────────────────
|
||||
DEFAULT_QUERIES = {
|
||||
"tavern": "D&D fantasy tavern inn ambience background music",
|
||||
"forest": "D&D forest enchanted woodland ambience background music",
|
||||
"combat": "D&D combat battle epic fantasy music",
|
||||
"dungeon": "D&D dungeon cave ambience background music",
|
||||
"tension": "D&D tension suspense dark ambient music",
|
||||
"calm": "D&D calm peaceful relaxing ambience",
|
||||
"town": "D&D town village marketplace ambience",
|
||||
"wilds": "D&D wilderness nature ambient music",
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────
|
||||
def _import_ytdlp():
|
||||
try:
|
||||
from yt_dlp import YoutubeDL
|
||||
return YoutubeDL
|
||||
except ImportError:
|
||||
print("Error: yt-dlp not found. Install it with: pip install yt-dlp")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _check_ffmpeg():
|
||||
import shutil
|
||||
if not shutil.which("ffmpeg"):
|
||||
print("Error: ffmpeg not found. Install it (apt install ffmpeg, brew install ffmpeg, etc.)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def sanitize_filename(name):
|
||||
safe = "-_.() " + string.ascii_letters + string.digits
|
||||
return "".join(c if c in safe else "_" for c in name).strip()
|
||||
|
||||
|
||||
def make_stem(ambience, existing_files):
|
||||
"""Generate next sequential stem, e.g. tavern_03"""
|
||||
nums = []
|
||||
for f in existing_files:
|
||||
m = re.search(rf"^{re.escape(ambience)}_(\d+)", f.stem)
|
||||
if m:
|
||||
nums.append(int(m.group(1)))
|
||||
idx = max(nums) + 1 if nums else 1
|
||||
return f"{ambience}_{idx:02d}"
|
||||
|
||||
|
||||
# ── YouTube search / info ─────────────────────────────────
|
||||
def search_videos(query, max_results=20):
|
||||
YoutubeDL = _import_ytdlp()
|
||||
ydl = YoutubeDL({"quiet": True, "no_warnings": True})
|
||||
info = ydl.extract_info(f"ytsearch{max_results}:{query}", download=False)
|
||||
return info.get("entries", [])
|
||||
|
||||
|
||||
def get_video_info(url):
|
||||
YoutubeDL = _import_ytdlp()
|
||||
ydl = YoutubeDL({"quiet": True, "no_warnings": True})
|
||||
return ydl.extract_info(url, download=False)
|
||||
|
||||
|
||||
# ── Download ──────────────────────────────────────────────
|
||||
def download_audio(url, outtmpl):
|
||||
YoutubeDL = _import_ytdlp()
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"outtmpl": outtmpl,
|
||||
"format": "bestaudio/best",
|
||||
"postprocessors": [{
|
||||
"key": "FFmpegExtractAudio",
|
||||
"preferredcodec": "vorbis",
|
||||
"preferredquality": "192",
|
||||
}],
|
||||
}
|
||||
with YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([url])
|
||||
|
||||
|
||||
# ── ambience_options.md table helpers ─────────────────────
|
||||
def _find_table_bounds(lines):
|
||||
"""Return (start, end) of the first contiguous |...| table block."""
|
||||
start = end = None
|
||||
for i, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
is_row = s.startswith("|") and s.endswith("|")
|
||||
if is_row and start is None:
|
||||
start = i
|
||||
if is_row:
|
||||
end = i + 1
|
||||
elif start is not None:
|
||||
break
|
||||
return start, end if end else start
|
||||
|
||||
|
||||
def parse_table():
|
||||
"""Parse ambience_options.md table into {name: [filename, ...]}"""
|
||||
if not OPTIONS_PATH.exists():
|
||||
return {}
|
||||
lines = OPTIONS_PATH.read_text().splitlines()
|
||||
start, end = _find_table_bounds(lines)
|
||||
if start is None:
|
||||
return {}
|
||||
entries = {}
|
||||
for line in lines[start:end]:
|
||||
s = line.strip()
|
||||
parts = [p.strip() for p in s.split("|")]
|
||||
parts = [p for p in parts if p]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
if all(c in "-:| " for c in s):
|
||||
continue
|
||||
if parts[0].lower() == "ambience":
|
||||
continue
|
||||
name = parts[0].lower()
|
||||
raw = parts[1]
|
||||
if raw == "(stops all music)" or raw == "(no files)":
|
||||
entries[name] = []
|
||||
else:
|
||||
entries[name] = [AUDIO_DIR / f.strip() for f in raw.split(",") if f.strip()]
|
||||
return entries
|
||||
|
||||
|
||||
def rebuild_table(entries):
|
||||
"""Render entries as a markdown table."""
|
||||
table = [
|
||||
"| Ambience | Files |",
|
||||
"|----------|-------|",
|
||||
]
|
||||
if "silence" in entries or not entries:
|
||||
table.append("| silence | (stops all music) |")
|
||||
for name in sorted(entries.keys()):
|
||||
if name == "silence":
|
||||
continue
|
||||
files = entries[name]
|
||||
if files:
|
||||
table.append(f"| {name} | {', '.join(f.name for f in files)} |")
|
||||
else:
|
||||
table.append(f"| {name} | (no files) |")
|
||||
return "\n".join(table)
|
||||
|
||||
|
||||
def update_options_file(entries):
|
||||
"""Replace the table in ambience_options.md while preserving surrounding text."""
|
||||
if not OPTIONS_PATH.exists():
|
||||
OPTIONS_PATH.write_text("# Ambience Options\n\n" + rebuild_table(entries) + "\n")
|
||||
return
|
||||
lines = OPTIONS_PATH.read_text().splitlines()
|
||||
start, end = _find_table_bounds(lines)
|
||||
new_table = rebuild_table(entries)
|
||||
if start is None:
|
||||
# No existing table — append after a blank line
|
||||
body = "\n".join(lines) + "\n\n" + new_table + "\n"
|
||||
else:
|
||||
body = "\n".join(lines[:start]) + "\n" + new_table + "\n" + "\n".join(lines[end:])
|
||||
body = body.strip() + "\n"
|
||||
OPTIONS_PATH.write_text(body)
|
||||
|
||||
|
||||
# ── ambience_sources.md helpers ──────────────────────────
|
||||
def _find_sources_table_bounds(lines):
|
||||
"""Return (start, end) of the sources table block."""
|
||||
start = end = None
|
||||
for i, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
is_row = s.startswith("|") and s.endswith("|")
|
||||
if is_row and start is None:
|
||||
start = i
|
||||
if is_row:
|
||||
end = i + 1
|
||||
elif start is not None:
|
||||
break
|
||||
return start, end if end else start
|
||||
|
||||
|
||||
def parse_sources():
|
||||
"""Parse ambience_sources.md into {filename: (ambience, url)}"""
|
||||
if not SOURCES_PATH.exists():
|
||||
return {}
|
||||
lines = SOURCES_PATH.read_text().splitlines()
|
||||
start, end = _find_sources_table_bounds(lines)
|
||||
if start is None:
|
||||
return {}
|
||||
sources = {}
|
||||
for line in lines[start:end]:
|
||||
s = line.strip()
|
||||
parts = [p.strip() for p in s.split("|")]
|
||||
parts = [p for p in parts if p]
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
if all(c in "-:| " for c in s):
|
||||
continue
|
||||
if parts[0].lower() == "file":
|
||||
continue
|
||||
filename, ambience, url = parts[0], parts[1], parts[2]
|
||||
sources[filename] = (ambience, url)
|
||||
return sources
|
||||
|
||||
|
||||
def rebuild_sources_table(sources):
|
||||
"""Render sources dict as a markdown table."""
|
||||
table = [
|
||||
"| File | Ambience | Source |",
|
||||
"|------|----------|--------|",
|
||||
]
|
||||
for filename in sorted(sources.keys()):
|
||||
ambience, url = sources[filename]
|
||||
table.append(f"| {filename} | {ambience} | {url} |")
|
||||
return "\n".join(table)
|
||||
|
||||
|
||||
def update_sources_file(sources):
|
||||
"""Replace the table in ambience_sources.md while preserving surrounding text."""
|
||||
if not SOURCES_PATH.exists():
|
||||
SOURCES_PATH.write_text("# Ambience Sources\n\n" + rebuild_sources_table(sources) + "\n")
|
||||
return
|
||||
lines = SOURCES_PATH.read_text().splitlines()
|
||||
start, end = _find_sources_table_bounds(lines)
|
||||
new_table = rebuild_sources_table(sources)
|
||||
if start is None:
|
||||
body = "\n".join(lines) + "\n\n" + new_table + "\n"
|
||||
else:
|
||||
body = "\n".join(lines[:start]) + "\n" + new_table + "\n" + "\n".join(lines[end:])
|
||||
body = body.strip() + "\n"
|
||||
SOURCES_PATH.write_text(body)
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch ambience music from YouTube and wire into the Chaos TUI.",
|
||||
)
|
||||
parser.add_argument("ambience", help="Ambience name (e.g. tavern, forest, combat)")
|
||||
parser.add_argument("query", nargs="?", default=None,
|
||||
help="Search query (auto-generated from ambience name if omitted)")
|
||||
parser.add_argument("--url", default=None,
|
||||
help="Specific YouTube URL (overrides search)")
|
||||
parser.add_argument("--replace", action="store_true",
|
||||
help="Replace existing tracks for this ambience instead of appending")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be fetched without downloading")
|
||||
parser.add_argument("--max-results", type=int, default=20,
|
||||
help="Number of search results to consider (default: 20)")
|
||||
args = parser.parse_args()
|
||||
|
||||
name = args.ambience.strip().lower()
|
||||
if not name:
|
||||
print("Error: ambience name is required.")
|
||||
sys.exit(1)
|
||||
|
||||
_check_ffmpeg()
|
||||
|
||||
# Resolve query
|
||||
query = args.query
|
||||
if not query:
|
||||
if name in DEFAULT_QUERIES:
|
||||
query = DEFAULT_QUERIES[name]
|
||||
else:
|
||||
query = f"D&D {name} ambience background music"
|
||||
print(f" query: {query}")
|
||||
|
||||
# Parse existing table
|
||||
entries = parse_table()
|
||||
existing = entries.get(name, [])
|
||||
|
||||
if args.replace:
|
||||
existing = []
|
||||
|
||||
# Get candidate video(s)
|
||||
if args.url:
|
||||
YoutubeDL = _import_ytdlp()
|
||||
ydl = YoutubeDL({"quiet": True, "no_warnings": True})
|
||||
video = ydl.extract_info(args.url, download=False)
|
||||
candidates = [video]
|
||||
source_desc = args.url
|
||||
else:
|
||||
candidates = search_videos(query, args.max_results)
|
||||
if not candidates:
|
||||
print("No results found.")
|
||||
sys.exit(1)
|
||||
source_desc = f"top {len(candidates)} results for \"{query}\""
|
||||
# Pick randomly from the pool
|
||||
video = random.choice(candidates)
|
||||
candidates = [video]
|
||||
|
||||
video = candidates[0]
|
||||
title = video.get("title", "unknown")
|
||||
url = video.get("webpage_url", video.get("url", args.url or "?"))
|
||||
duration = video.get("duration", 0)
|
||||
mins, secs = divmod(duration, 60) if duration else (0, 0)
|
||||
|
||||
print(f" picked: \"{title}\"")
|
||||
print(f" url: {url}")
|
||||
print(f" length: {mins}:{secs:02d}")
|
||||
|
||||
if args.dry_run:
|
||||
stem = make_stem(name, existing)
|
||||
print(f" target: {AUDIO_DIR / (stem + '.ogg')}")
|
||||
print(" (dry-run, nothing saved)")
|
||||
return
|
||||
|
||||
# Download
|
||||
stem = make_stem(name, existing)
|
||||
outtmpl = str(AUDIO_DIR / stem) # no extension — yt-dlp handles it
|
||||
|
||||
print(f" downloading...", end=" ", flush=True)
|
||||
download_audio(url, outtmpl)
|
||||
target = AUDIO_DIR / f"{stem}.ogg"
|
||||
print("done.")
|
||||
|
||||
if not target.exists():
|
||||
print(f"Warning: expected file not found at {target}")
|
||||
print(f"Check session/audio/ for the actual output.")
|
||||
sys.exit(1)
|
||||
|
||||
# Wire into options config
|
||||
existing.append(target)
|
||||
entries[name] = existing
|
||||
update_options_file(entries)
|
||||
|
||||
# Record source
|
||||
sources = parse_sources()
|
||||
if args.replace:
|
||||
# Remove stale entries for this ambience
|
||||
sources = {k: v for k, v in sources.items() if v[0] != name}
|
||||
sources[target.name] = (name, url)
|
||||
update_sources_file(sources)
|
||||
|
||||
total = len(existing)
|
||||
print(f" saved: {target}")
|
||||
print(f" wired: {name} now has {total} track{'s' if total != 1 else ''}")
|
||||
print(f" source: recorded in ambience_sources.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
188
tools/run.py
188
tools/run.py
@ -3,9 +3,11 @@
|
||||
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
|
||||
@ -16,6 +18,13 @@ 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
|
||||
@ -23,6 +32,10 @@ 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'
|
||||
|
||||
@ -39,6 +52,22 @@ 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 []
|
||||
@ -85,6 +114,107 @@ 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."""
|
||||
@ -97,6 +227,11 @@ class AutoStatic(Static):
|
||||
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()
|
||||
@ -113,7 +248,16 @@ class StatusBar(AutoStatic):
|
||||
def load(self):
|
||||
char = status_summary()
|
||||
count = log_count()
|
||||
self.update(f"{char} │ {count} entries │ {TODAY}")
|
||||
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 ──────────────────────────────────────────────
|
||||
@ -124,7 +268,6 @@ class ChaosTUI(App):
|
||||
background: #121212;
|
||||
}
|
||||
|
||||
/* ── Top banner ── */
|
||||
#banner {
|
||||
dock: top;
|
||||
height: 1;
|
||||
@ -133,7 +276,6 @@ class ChaosTUI(App):
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Bottom input ── */
|
||||
#input-row {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
@ -151,16 +293,28 @@ class ChaosTUI(App):
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ── Main area: log (left) + sidebar (right) ── */
|
||||
#main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Log column */
|
||||
#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;
|
||||
@ -173,7 +327,6 @@ class ChaosTUI(App):
|
||||
color: #c8c8c8;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
width: 36;
|
||||
min-width: 28;
|
||||
@ -204,10 +357,20 @@ class ChaosTUI(App):
|
||||
("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"):
|
||||
@ -221,6 +384,12 @@ class ChaosTUI(App):
|
||||
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):
|
||||
@ -228,12 +397,17 @@ class ChaosTUI(App):
|
||||
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():
|
||||
app = ChaosTUI()
|
||||
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()
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user