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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
|
session/audio/
|
||||||
|
|||||||
25
AGENTS.md
25
AGENTS.md
@ -9,11 +9,16 @@ the-chaos/
|
|||||||
├── rules/ # LOCKED — the game itself, do not modify
|
├── rules/ # LOCKED — the game itself, do not modify
|
||||||
│ ├── deck/ # Card tables (souls, cook, creatures, curiosities)
|
│ ├── deck/ # Card tables (souls, cook, creatures, curiosities)
|
||||||
│ └── mechanics.md # Core rules reference
|
│ └── mechanics.md # Core rules reference
|
||||||
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py)
|
├── tools/ # LOCKED — CLI helpers (draw.py, roll.py, run.py, ambience.py)
|
||||||
└── session/ # UNLOCKED — our campaign
|
└── session/ # UNLOCKED — our campaign
|
||||||
├── character.md # Player character sheet
|
├── character.md # Player character sheet
|
||||||
├── world.md # Keep & Realm state (NPCs, locations, threads)
|
├── world.md # Keep & Realm state (NPCs, locations, threads)
|
||||||
|
├── journal.md # TODO / DONE task tracking
|
||||||
├── tweaks.md # House rules log
|
├── 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
|
└── log/ # Raw session logs by date
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -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.
|
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
|
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
|
4. **Player rolls dice physically** — they report results, you narrate outcomes
|
||||||
5. **Log everything** — after each meaningful beat, append to `session/log/<today>.md`
|
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. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away
|
6. **Keep journal.md** — Add tasks to `session/journal.md` under `## TODO`. Move them to `## DONE` when completed.
|
||||||
7. **Keep tweaks.md** — if you make a house rule or add a custom table, log it in `tweaks.md`
|
7. **Update files immediately** — damage taken, loot gained, NPCs met → update `character.md` and `world.md` right away, before the next narration.
|
||||||
8. **Death is real** — if the PC dies, help the player roll a new character. That's the game.
|
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 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 |
|
| 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
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import sys
|
import sys
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -16,6 +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 ───────────────────────────────────────
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
HAS_PYGAME = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PYGAME = False
|
||||||
|
|
||||||
|
|
||||||
# ── Paths ────────────────────────────────────────────────
|
# ── Paths ────────────────────────────────────────────────
|
||||||
BASE = Path(__file__).resolve().parent.parent
|
BASE = Path(__file__).resolve().parent.parent
|
||||||
@ -23,6 +32,10 @@ SESSION = BASE / 'session'
|
|||||||
LOG_DIR = SESSION / 'log'
|
LOG_DIR = SESSION / 'log'
|
||||||
CHAR_PATH = SESSION / 'character.md'
|
CHAR_PATH = SESSION / 'character.md'
|
||||||
WORLD_PATH = SESSION / 'world.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()
|
TODAY = date.today().isoformat()
|
||||||
LOG_PATH = LOG_DIR / f'{TODAY}.md'
|
LOG_PATH = LOG_DIR / f'{TODAY}.md'
|
||||||
|
|
||||||
@ -39,6 +52,22 @@ def append_log(text):
|
|||||||
with open(LOG_PATH, 'a') as f:
|
with open(LOG_PATH, 'a') as f:
|
||||||
f.write(f"- {text}\n")
|
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):
|
def read_log_tail(n=200):
|
||||||
if not LOG_PATH.exists():
|
if not LOG_PATH.exists():
|
||||||
return []
|
return []
|
||||||
@ -85,6 +114,107 @@ def log_count():
|
|||||||
return len(read_log_tail())
|
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 ───────────────────────────────
|
# ── Auto-refreshing panels ───────────────────────────────
|
||||||
class AutoStatic(Static):
|
class AutoStatic(Static):
|
||||||
"""A Static that reloads its content on an interval."""
|
"""A Static that reloads its content on an interval."""
|
||||||
@ -97,6 +227,11 @@ class AutoStatic(Static):
|
|||||||
self.set_interval(REFRESH_SECS, 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):
|
class TranscriptPane(AutoStatic):
|
||||||
def load(self):
|
def load(self):
|
||||||
lines = read_log_tail()
|
lines = read_log_tail()
|
||||||
@ -113,7 +248,16 @@ class StatusBar(AutoStatic):
|
|||||||
def load(self):
|
def load(self):
|
||||||
char = status_summary()
|
char = status_summary()
|
||||||
count = log_count()
|
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 ──────────────────────────────────────────────
|
# ── The App ──────────────────────────────────────────────
|
||||||
@ -124,7 +268,6 @@ class ChaosTUI(App):
|
|||||||
background: #121212;
|
background: #121212;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Top banner ── */
|
|
||||||
#banner {
|
#banner {
|
||||||
dock: top;
|
dock: top;
|
||||||
height: 1;
|
height: 1;
|
||||||
@ -133,7 +276,6 @@ class ChaosTUI(App):
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bottom input ── */
|
|
||||||
#input-row {
|
#input-row {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
height: 3;
|
height: 3;
|
||||||
@ -151,16 +293,28 @@ class ChaosTUI(App):
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main area: log (left) + sidebar (right) ── */
|
|
||||||
#main {
|
#main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Log column */
|
|
||||||
#log-col {
|
#log-col {
|
||||||
border-right: solid #3a3a3a;
|
border-right: solid #3a3a3a;
|
||||||
background: #111111;
|
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 {
|
#log-header {
|
||||||
background: #1d2d1d;
|
background: #1d2d1d;
|
||||||
color: #7dcd7d;
|
color: #7dcd7d;
|
||||||
@ -173,7 +327,6 @@ class ChaosTUI(App):
|
|||||||
color: #c8c8c8;
|
color: #c8c8c8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar */
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
width: 36;
|
width: 36;
|
||||||
min-width: 28;
|
min-width: 28;
|
||||||
@ -204,10 +357,20 @@ class ChaosTUI(App):
|
|||||||
("escape", "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):
|
def compose(self):
|
||||||
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
|
yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner")
|
||||||
with Horizontal(id="main"):
|
with Horizontal(id="main"):
|
||||||
with Vertical(id="log-col"):
|
with Vertical(id="log-col"):
|
||||||
|
yield Static("TODO", id="todo-header")
|
||||||
|
yield TodoPane(id="todo-content")
|
||||||
yield Static("LOG", id="log-header")
|
yield Static("LOG", id="log-header")
|
||||||
yield TranscriptPane(id="transcript")
|
yield TranscriptPane(id="transcript")
|
||||||
with Vertical(id="sidebar"):
|
with Vertical(id="sidebar"):
|
||||||
@ -221,6 +384,12 @@ class ChaosTUI(App):
|
|||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
ensure_log()
|
ensure_log()
|
||||||
self.input.focus()
|
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")
|
@on(Input.Submitted, "#input")
|
||||||
def on_input(self, event: Input.Submitted):
|
def on_input(self, event: Input.Submitted):
|
||||||
@ -228,12 +397,17 @@ class ChaosTUI(App):
|
|||||||
if text:
|
if text:
|
||||||
append_log(text)
|
append_log(text)
|
||||||
self.input.clear()
|
self.input.clear()
|
||||||
|
self.query_one(TodoPane).load()
|
||||||
self.query_one(TranscriptPane).load()
|
self.query_one(TranscriptPane).load()
|
||||||
self.query_one(StatusBar).load()
|
self.query_one(StatusBar).load()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
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()
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user