Spaces:
Running
Running
Basic Only version step 3
Browse filesremoved additional dead code
- battlewords/audio.py +0 -254
- battlewords/generate_sounds.py +0 -174
- battlewords/local_storage.py +0 -234
- battlewords/modules/__init__.py +0 -12
- battlewords/modules/file_utils.py +0 -204
- battlewords/settings_page.py +0 -304
- battlewords/sounds.py +0 -195
battlewords/audio.py
DELETED
|
@@ -1,254 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from typing import Optional
|
| 3 |
-
import streamlit as st
|
| 4 |
-
import time
|
| 5 |
-
|
| 6 |
-
def _get_music_dir() -> str:
|
| 7 |
-
return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
|
| 8 |
-
|
| 9 |
-
def _get_effects_dir() -> str:
|
| 10 |
-
return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
|
| 11 |
-
|
| 12 |
-
def get_audio_tracks() -> list[tuple[str, str]]:
|
| 13 |
-
"""Return list of (label, absolute_path) for .mp3 files in assets/audio/music."""
|
| 14 |
-
audio_dir = _get_music_dir()
|
| 15 |
-
if not os.path.isdir(audio_dir):
|
| 16 |
-
return []
|
| 17 |
-
tracks = []
|
| 18 |
-
for fname in os.listdir(audio_dir):
|
| 19 |
-
if fname.lower().endswith('.mp3'):
|
| 20 |
-
path = os.path.join(audio_dir, fname)
|
| 21 |
-
# Use the filename without extension as the display name
|
| 22 |
-
name = os.path.splitext(fname)[0]
|
| 23 |
-
tracks.append((name, path))
|
| 24 |
-
return tracks
|
| 25 |
-
|
| 26 |
-
@st.cache_data(show_spinner=False)
|
| 27 |
-
def _load_audio_data_url(path: str) -> str:
|
| 28 |
-
"""Return a data: URL for the given audio file so the browser can play it."""
|
| 29 |
-
import base64, mimetypes
|
| 30 |
-
mime, _ = mimetypes.guess_type(path)
|
| 31 |
-
if not mime:
|
| 32 |
-
# Default to mp3 to avoid blocked playback if unknown
|
| 33 |
-
mime = "audio/mpeg"
|
| 34 |
-
with open(path, "rb") as fp:
|
| 35 |
-
encoded = base64.b64encode(fp.read()).decode("ascii")
|
| 36 |
-
return f"data:{mime};base64,{encoded}"
|
| 37 |
-
|
| 38 |
-
def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume: float, loop: bool = True) -> None:
|
| 39 |
-
"""Create/update a single hidden <audio> element in the top page and play/pause it.
|
| 40 |
-
|
| 41 |
-
Args:
|
| 42 |
-
enabled: Whether the background audio should be active.
|
| 43 |
-
src_data_url: data: URL for the audio source.
|
| 44 |
-
volume: 0.0–1.0 volume level.
|
| 45 |
-
loop: Whether the audio should loop (default True).
|
| 46 |
-
"""
|
| 47 |
-
from streamlit.components.v1 import html as _html
|
| 48 |
-
|
| 49 |
-
if not enabled or not src_data_url:
|
| 50 |
-
_html(
|
| 51 |
-
"""
|
| 52 |
-
<script>
|
| 53 |
-
(function(){
|
| 54 |
-
const doc = window.parent?.document || document;
|
| 55 |
-
const el = doc.getElementById('bw-bg-audio');
|
| 56 |
-
if (el) { try { el.pause(); } catch(e){} }
|
| 57 |
-
})();
|
| 58 |
-
</script>
|
| 59 |
-
""",
|
| 60 |
-
height=0,
|
| 61 |
-
)
|
| 62 |
-
return
|
| 63 |
-
|
| 64 |
-
# Clamp volume
|
| 65 |
-
vol = max(0.0, min(1.0, float(volume)))
|
| 66 |
-
should_loop = "true" if loop else "false"
|
| 67 |
-
|
| 68 |
-
# Inject or update a single persistent audio element and make sure it starts after interaction if autoplay is blocked
|
| 69 |
-
_html(
|
| 70 |
-
f"""
|
| 71 |
-
<script>
|
| 72 |
-
(function(){{
|
| 73 |
-
const doc = window.parent?.document || document;
|
| 74 |
-
let audio = doc.getElementById('bw-bg-audio');
|
| 75 |
-
if (!audio) {{
|
| 76 |
-
audio = doc.createElement('audio');
|
| 77 |
-
audio.id = 'bw-bg-audio';
|
| 78 |
-
audio.style.display = 'none';
|
| 79 |
-
doc.body.appendChild(audio);
|
| 80 |
-
}}
|
| 81 |
-
|
| 82 |
-
// Ensure loop is explicitly set every time, even if element already exists
|
| 83 |
-
const shouldLoop = {should_loop};
|
| 84 |
-
audio.loop = shouldLoop;
|
| 85 |
-
if (shouldLoop) {{
|
| 86 |
-
audio.setAttribute('loop', '');
|
| 87 |
-
}} else {{
|
| 88 |
-
audio.removeAttribute('loop');
|
| 89 |
-
}}
|
| 90 |
-
audio.autoplay = true;
|
| 91 |
-
audio.setAttribute('autoplay', '');
|
| 92 |
-
|
| 93 |
-
const newSrc = "{src_data_url}";
|
| 94 |
-
if (audio.src !== newSrc) {{
|
| 95 |
-
audio.src = newSrc;
|
| 96 |
-
}}
|
| 97 |
-
audio.muted = false;
|
| 98 |
-
audio.volume = {vol:.3f};
|
| 99 |
-
|
| 100 |
-
const tryPlay = () => {{
|
| 101 |
-
const p = audio.play();
|
| 102 |
-
if (p && p.catch) {{ p.catch(() => {{ /* ignore autoplay block until user gesture */ }}); }}
|
| 103 |
-
}};
|
| 104 |
-
tryPlay();
|
| 105 |
-
|
| 106 |
-
const unlock = () => {{
|
| 107 |
-
tryPlay();
|
| 108 |
-
}};
|
| 109 |
-
// Add once-only listeners to resume playback after first user interaction
|
| 110 |
-
doc.addEventListener('pointerdown', unlock, {{ once: true }});
|
| 111 |
-
doc.addEventListener('keydown', unlock, {{ once: true }});
|
| 112 |
-
doc.addEventListener('touchstart', unlock, {{ once: true }});
|
| 113 |
-
}})();
|
| 114 |
-
</script>
|
| 115 |
-
""",
|
| 116 |
-
height=0,
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
def _inject_audio_control_sync():
|
| 120 |
-
"""Inject JS to sync volume and enable/disable state immediately."""
|
| 121 |
-
from streamlit.components.v1 import html as _html
|
| 122 |
-
_html(
|
| 123 |
-
'''
|
| 124 |
-
<script>
|
| 125 |
-
(function(){
|
| 126 |
-
const doc = window.parent?.document || document;
|
| 127 |
-
const audio = doc.getElementById('bw-bg-audio');
|
| 128 |
-
if (!audio) return;
|
| 129 |
-
// Get values from Streamlit DOM
|
| 130 |
-
const volInput = doc.querySelector('input[type="range"][aria-label="Volume"]');
|
| 131 |
-
const enableInput = doc.querySelector('input[type="checkbox"][aria-label="Enable music"]');
|
| 132 |
-
if (volInput) {
|
| 133 |
-
volInput.addEventListener('input', function(){
|
| 134 |
-
audio.volume = parseFloat(this.value)/100;
|
| 135 |
-
});
|
| 136 |
-
// Set initial volume
|
| 137 |
-
audio.volume = parseFloat(volInput.value)/100;
|
| 138 |
-
}
|
| 139 |
-
if (enableInput) {
|
| 140 |
-
enableInput.addEventListener('change', function(){
|
| 141 |
-
if (this.checked) {
|
| 142 |
-
audio.muted = false;
|
| 143 |
-
audio.play().catch(()=>{});
|
| 144 |
-
} else {
|
| 145 |
-
audio.muted = true;
|
| 146 |
-
audio.pause();
|
| 147 |
-
}
|
| 148 |
-
});
|
| 149 |
-
// Set initial mute state
|
| 150 |
-
if (enableInput.checked) {
|
| 151 |
-
audio.muted = false;
|
| 152 |
-
audio.play().catch(()=>{});
|
| 153 |
-
} else {
|
| 154 |
-
audio.muted = true;
|
| 155 |
-
audio.pause();
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
})();
|
| 159 |
-
</script>
|
| 160 |
-
''',
|
| 161 |
-
height=0,
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
-
# Sound effects functionality
|
| 165 |
-
def get_sound_effect_files() -> dict[str, str]:
|
| 166 |
-
"""
|
| 167 |
-
Return dictionary of sound effect name -> absolute path.
|
| 168 |
-
Prefers .mp3 files; falls back to .wav if no .mp3 is found.
|
| 169 |
-
"""
|
| 170 |
-
audio_dir = _get_effects_dir()
|
| 171 |
-
if not os.path.isdir(audio_dir):
|
| 172 |
-
return {}
|
| 173 |
-
|
| 174 |
-
effect_names = [
|
| 175 |
-
"correct_guess",
|
| 176 |
-
"incorrect_guess",
|
| 177 |
-
"hit",
|
| 178 |
-
"miss",
|
| 179 |
-
"congratulations",
|
| 180 |
-
]
|
| 181 |
-
|
| 182 |
-
def _find_effect_file(base: str) -> Optional[str]:
|
| 183 |
-
# Prefer mp3, then wav for backward compatibility
|
| 184 |
-
for ext in (".mp3", ".wav"):
|
| 185 |
-
path = os.path.join(audio_dir, f"{base}{ext}")
|
| 186 |
-
if os.path.exists(path):
|
| 187 |
-
return path
|
| 188 |
-
return None
|
| 189 |
-
|
| 190 |
-
result: dict[str, str] = {}
|
| 191 |
-
for name in effect_names:
|
| 192 |
-
path = _find_effect_file(name)
|
| 193 |
-
if path:
|
| 194 |
-
result[name] = path
|
| 195 |
-
|
| 196 |
-
return result
|
| 197 |
-
|
| 198 |
-
def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
| 199 |
-
"""
|
| 200 |
-
Play a sound effect by name.
|
| 201 |
-
|
| 202 |
-
Args:
|
| 203 |
-
effect_name: One of 'correct_guess', 'incorrect_guess', 'hit', 'miss', 'congratulations'
|
| 204 |
-
volume: Volume level (0.0 to 1.0)
|
| 205 |
-
"""
|
| 206 |
-
from streamlit.components.v1 import html as _html
|
| 207 |
-
|
| 208 |
-
# Respect Enable Sound Effects setting from sidebar
|
| 209 |
-
try:
|
| 210 |
-
if not st.session_state.get("enable_sound_effects", True):
|
| 211 |
-
# print(f"[DEBUG] Sound effects disabled; skipping sound effect '{effect_name}'.")
|
| 212 |
-
return
|
| 213 |
-
except Exception:
|
| 214 |
-
pass
|
| 215 |
-
|
| 216 |
-
sound_files = get_sound_effect_files()
|
| 217 |
-
|
| 218 |
-
if effect_name not in sound_files:
|
| 219 |
-
print(f"[DEBUG] Sound effect '{effect_name}' not found in available sound files.")
|
| 220 |
-
return # Sound file doesn't exist, silently skip
|
| 221 |
-
|
| 222 |
-
sound_path = sound_files[effect_name]
|
| 223 |
-
try:
|
| 224 |
-
sound_data_url = _load_audio_data_url(sound_path)
|
| 225 |
-
except Exception as e:
|
| 226 |
-
print(f"[DEBUG] Failed to load audio data for '{effect_name}' at '{sound_path}': {e}")
|
| 227 |
-
return
|
| 228 |
-
|
| 229 |
-
# Clamp volume
|
| 230 |
-
vol = max(0.0, min(1.0, float(volume)))
|
| 231 |
-
|
| 232 |
-
# Play sound effect using a unique audio element
|
| 233 |
-
_html(
|
| 234 |
-
f"""
|
| 235 |
-
<script>
|
| 236 |
-
(function(){{
|
| 237 |
-
const doc = window.parent?.document || document;
|
| 238 |
-
const audio = doc.createElement('audio');
|
| 239 |
-
audio.src = "{sound_data_url}";
|
| 240 |
-
audio.volume = {vol:.3f};
|
| 241 |
-
audio.style.display = 'none';
|
| 242 |
-
doc.body.appendChild(audio);
|
| 243 |
-
|
| 244 |
-
// Play and remove after playback
|
| 245 |
-
audio.play().catch(e => console.error('Sound effect play error:', e));
|
| 246 |
-
audio.addEventListener('ended', () => {{
|
| 247 |
-
doc.body.removeChild(audio);
|
| 248 |
-
}});
|
| 249 |
-
}})();
|
| 250 |
-
</script>
|
| 251 |
-
""",
|
| 252 |
-
height=0,
|
| 253 |
-
)
|
| 254 |
-
time.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
battlewords/generate_sounds.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Standalone script to generate sound effects using Hugging Face API.
|
| 4 |
-
Uses only built-in Python libraries (no external dependencies).
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
import json
|
| 9 |
-
import urllib.request
|
| 10 |
-
import time
|
| 11 |
-
from pathlib import Path
|
| 12 |
-
|
| 13 |
-
# Load environment variables from .env if present
|
| 14 |
-
env_path = Path(__file__).parent / ".env"
|
| 15 |
-
if env_path.exists():
|
| 16 |
-
with open(env_path) as f:
|
| 17 |
-
for line in f:
|
| 18 |
-
if line.strip() and not line.startswith("#"):
|
| 19 |
-
key, _, value = line.strip().partition("=")
|
| 20 |
-
os.environ[key] = value
|
| 21 |
-
|
| 22 |
-
# Get Hugging Face API token from environment variable
|
| 23 |
-
HF_API_TOKEN = os.environ.get("HF_API_TOKEN")
|
| 24 |
-
if not HF_API_TOKEN:
|
| 25 |
-
print("Warning: HF_API_TOKEN not set in environment or .env file.")
|
| 26 |
-
|
| 27 |
-
# Using your UnlimitedMusicGen Gradio Space
|
| 28 |
-
SPACE_URL = "https://surn-unlimitedmusicgen.hf.space"
|
| 29 |
-
GRADIO_API_URL = f"{SPACE_URL}/api/predict"
|
| 30 |
-
GRADIO_STATUS_URL = f"{SPACE_URL}/call/predict/{{event_id}}"
|
| 31 |
-
|
| 32 |
-
# Sound effects to generate
|
| 33 |
-
EFFECT_PROMPTS = {
|
| 34 |
-
"correct_guess": {"prompt": "A short, sharp ding sound for a correct guess", "duration": 2},
|
| 35 |
-
"incorrect_guess": {"prompt": "A low buzz sound for an incorrect guess", "duration": 2},
|
| 36 |
-
"miss": {"prompt": "A soft thud sound for a miss", "duration": 1},
|
| 37 |
-
"hit": {"prompt": "A bright chime sound for a hit", "duration": 1},
|
| 38 |
-
"congratulations": {"prompt": "A triumphant fanfare sound for congratulations", "duration": 3}
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
def generate_sound_effect_gradio(effect_name: str, prompt: str, duration: float, output_dir: Path) -> bool:
|
| 42 |
-
"""Generate a single sound effect using Gradio API (async call)."""
|
| 43 |
-
|
| 44 |
-
print(f"\nGenerating: {effect_name}")
|
| 45 |
-
print(f" Prompt: {prompt}")
|
| 46 |
-
print(f" Duration: {duration}s")
|
| 47 |
-
|
| 48 |
-
# Step 1: Submit generation request
|
| 49 |
-
payload = json.dumps({
|
| 50 |
-
"data": [prompt, duration]
|
| 51 |
-
}).encode('utf-8')
|
| 52 |
-
|
| 53 |
-
headers = {
|
| 54 |
-
"Content-Type": "application/json"
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
try:
|
| 58 |
-
print(f" Submitting request to Gradio API...")
|
| 59 |
-
|
| 60 |
-
# Submit the job
|
| 61 |
-
req = urllib.request.Request(GRADIO_API_URL, data=payload, headers=headers, method='POST')
|
| 62 |
-
|
| 63 |
-
with urllib.request.urlopen(req, timeout=30) as response:
|
| 64 |
-
if response.status == 200:
|
| 65 |
-
result = json.loads(response.read().decode())
|
| 66 |
-
event_id = result.get("event_id")
|
| 67 |
-
|
| 68 |
-
if not event_id:
|
| 69 |
-
print(f" ✗ No event_id returned")
|
| 70 |
-
return False
|
| 71 |
-
|
| 72 |
-
print(f" Job submitted, event_id: {event_id}")
|
| 73 |
-
|
| 74 |
-
# Step 2: Poll for results
|
| 75 |
-
status_url = GRADIO_STATUS_URL.format(event_id=event_id)
|
| 76 |
-
|
| 77 |
-
for poll_attempt in range(30): # Poll for up to 5 minutes
|
| 78 |
-
time.sleep(10)
|
| 79 |
-
print(f" Polling for results (attempt {poll_attempt + 1}/30)...")
|
| 80 |
-
|
| 81 |
-
status_req = urllib.request.Request(status_url, headers=headers)
|
| 82 |
-
|
| 83 |
-
try:
|
| 84 |
-
with urllib.request.urlopen(status_req, timeout=30) as status_response:
|
| 85 |
-
# Gradio returns streaming events, read until we get the result
|
| 86 |
-
for line in status_response:
|
| 87 |
-
line = line.decode('utf-8').strip()
|
| 88 |
-
if line.startswith('data: '):
|
| 89 |
-
event_data = json.loads(line[6:]) # Remove 'data: ' prefix
|
| 90 |
-
|
| 91 |
-
if event_data.get('msg') == 'process_completed':
|
| 92 |
-
# Get the audio file URL
|
| 93 |
-
output_data = event_data.get('output', {}).get('data', [])
|
| 94 |
-
if output_data and len(output_data) > 0:
|
| 95 |
-
audio_url = output_data[0].get('url')
|
| 96 |
-
if audio_url:
|
| 97 |
-
# Download the audio file
|
| 98 |
-
full_audio_url = f"https://surn-unlimitedmusicgen.hf.space{audio_url}"
|
| 99 |
-
print(f" Downloading from: {full_audio_url}")
|
| 100 |
-
|
| 101 |
-
audio_req = urllib.request.Request(full_audio_url)
|
| 102 |
-
with urllib.request.urlopen(audio_req, timeout=30) as audio_response:
|
| 103 |
-
audio_data = audio_response.read()
|
| 104 |
-
|
| 105 |
-
# Save to file
|
| 106 |
-
output_path = output_dir / f"{effect_name}.wav"
|
| 107 |
-
with open(output_path, "wb") as f:
|
| 108 |
-
f.write(audio_data)
|
| 109 |
-
|
| 110 |
-
print(f" ✓ Success! Saved to: {output_path}")
|
| 111 |
-
print(f" File size: {len(audio_data)} bytes")
|
| 112 |
-
return True
|
| 113 |
-
|
| 114 |
-
elif event_data.get('msg') == 'process_error':
|
| 115 |
-
print(f" ✗ Generation error: {event_data.get('output')}")
|
| 116 |
-
return False
|
| 117 |
-
|
| 118 |
-
except Exception as poll_error:
|
| 119 |
-
print(f" Polling error: {poll_error}")
|
| 120 |
-
continue
|
| 121 |
-
|
| 122 |
-
print(f" ✗ Timeout waiting for generation")
|
| 123 |
-
return False
|
| 124 |
-
|
| 125 |
-
else:
|
| 126 |
-
print(f" ✗ Error {response.status}: {response.read().decode()}")
|
| 127 |
-
return False
|
| 128 |
-
|
| 129 |
-
except Exception as e:
|
| 130 |
-
print(f" ✗ Error: {e}")
|
| 131 |
-
return False
|
| 132 |
-
|
| 133 |
-
def main():
|
| 134 |
-
"""Generate all sound effects."""
|
| 135 |
-
|
| 136 |
-
print("=" * 70)
|
| 137 |
-
print("Sound Effects Generator for BattleWords")
|
| 138 |
-
print("=" * 70)
|
| 139 |
-
print(f"Using UnlimitedMusicGen Gradio API")
|
| 140 |
-
print(f"API URL: {GRADIO_API_URL}")
|
| 141 |
-
print(f"\nGenerating {len(EFFECT_PROMPTS)} sound effects...\n")
|
| 142 |
-
|
| 143 |
-
# Create output directory
|
| 144 |
-
output_dir = Path(__file__).parent / "assets" / "audio"
|
| 145 |
-
output_dir.mkdir(parents=True, exist_ok=True)
|
| 146 |
-
print(f"Output directory: {output_dir}\n")
|
| 147 |
-
|
| 148 |
-
# Generate each effect
|
| 149 |
-
success_count = 0
|
| 150 |
-
for effect_name, config in EFFECT_PROMPTS.items():
|
| 151 |
-
if generate_sound_effect_gradio(
|
| 152 |
-
effect_name,
|
| 153 |
-
config["prompt"],
|
| 154 |
-
config["duration"],
|
| 155 |
-
output_dir
|
| 156 |
-
):
|
| 157 |
-
success_count += 1
|
| 158 |
-
|
| 159 |
-
# Small delay between requests
|
| 160 |
-
if effect_name != list(EFFECT_PROMPTS.keys())[-1]:
|
| 161 |
-
print(" Waiting 5 seconds before next request...")
|
| 162 |
-
time.sleep(5)
|
| 163 |
-
|
| 164 |
-
print("\n" + "=" * 70)
|
| 165 |
-
print(f"Generation complete! {success_count}/{len(EFFECT_PROMPTS)} successful")
|
| 166 |
-
print("=" * 70)
|
| 167 |
-
|
| 168 |
-
if success_count == len(EFFECT_PROMPTS):
|
| 169 |
-
print("\n✓ All sound effects generated successfully!")
|
| 170 |
-
else:
|
| 171 |
-
print(f"\n⚠ {len(EFFECT_PROMPTS) - success_count} sound effects failed to generate")
|
| 172 |
-
|
| 173 |
-
if __name__ == "__main__":
|
| 174 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
battlewords/local_storage.py
DELETED
|
@@ -1,234 +0,0 @@
|
|
| 1 |
-
# file: battlewords/local_storage.py
|
| 2 |
-
"""
|
| 3 |
-
Storage module for BattleWords game.
|
| 4 |
-
|
| 5 |
-
Provides functionality for:
|
| 6 |
-
1. Saving/loading game results to local JSON files
|
| 7 |
-
2. Managing high scores and leaderboards
|
| 8 |
-
3. Sharing game IDs via query strings
|
| 9 |
-
|
| 10 |
-
This module also provides minimal local settings persistence helpers.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
from dataclasses import dataclass, field, asdict
|
| 15 |
-
from typing import List, Dict, Optional, Any
|
| 16 |
-
from datetime import datetime
|
| 17 |
-
import json
|
| 18 |
-
import os
|
| 19 |
-
from pathlib import Path
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _get_settings_dir() -> str:
|
| 23 |
-
"""Return the directory where local settings are stored."""
|
| 24 |
-
return os.path.join(os.path.dirname(__file__), "settings")
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def load_latest_settings() -> Dict[str, Any]:
|
| 28 |
-
"""Load the most recently saved settings snapshot.
|
| 29 |
-
|
| 30 |
-
Returns:
|
| 31 |
-
Dict[str, Any]: Loaded settings or an empty dict if not found/invalid.
|
| 32 |
-
"""
|
| 33 |
-
settings_path = os.path.join(_get_settings_dir(), "settings.json")
|
| 34 |
-
if not os.path.exists(settings_path):
|
| 35 |
-
return {}
|
| 36 |
-
|
| 37 |
-
try:
|
| 38 |
-
with open(settings_path, "r", encoding="utf-8") as f:
|
| 39 |
-
data = json.load(f)
|
| 40 |
-
return data if isinstance(data, dict) else {}
|
| 41 |
-
except Exception:
|
| 42 |
-
return {}
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def save_active_settings(settings: Dict[str, Any]) -> str:
|
| 46 |
-
"""Persist the active settings snapshot to local storage.
|
| 47 |
-
|
| 48 |
-
Args:
|
| 49 |
-
settings: Settings dict to save.
|
| 50 |
-
|
| 51 |
-
Returns:
|
| 52 |
-
str: Full path to the written file.
|
| 53 |
-
"""
|
| 54 |
-
return save_json_to_file(settings, _get_settings_dir(), filename="settings.json")
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
@dataclass
|
| 58 |
-
class GameResult:
|
| 59 |
-
game_id: str
|
| 60 |
-
wordlist: str
|
| 61 |
-
game_mode: str
|
| 62 |
-
score: int
|
| 63 |
-
tier: str
|
| 64 |
-
elapsed_seconds: int
|
| 65 |
-
words_found: List[str]
|
| 66 |
-
completed_at: str
|
| 67 |
-
player_name: Optional[str] = None
|
| 68 |
-
user_id: Optional[str] = None
|
| 69 |
-
subscription_level: Optional[int] = 0
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def to_dict(self) -> Dict[str, Any]:
|
| 73 |
-
return asdict(self)
|
| 74 |
-
|
| 75 |
-
@classmethod
|
| 76 |
-
def from_dict(cls, data: Dict[str, Any]) -> "GameResult":
|
| 77 |
-
return cls(**data)
|
| 78 |
-
|
| 79 |
-
@dataclass
|
| 80 |
-
class HighScoreEntry:
|
| 81 |
-
player_name: str
|
| 82 |
-
score: int
|
| 83 |
-
tier: str
|
| 84 |
-
wordlist: str
|
| 85 |
-
game_mode: str
|
| 86 |
-
elapsed_seconds: int
|
| 87 |
-
completed_at: str
|
| 88 |
-
game_id: str
|
| 89 |
-
|
| 90 |
-
def to_dict(self) -> Dict[str, Any]:
|
| 91 |
-
return asdict(self)
|
| 92 |
-
|
| 93 |
-
@classmethod
|
| 94 |
-
def from_dict(cls, data: Dict[str, Any]) -> "HighScoreEntry":
|
| 95 |
-
return cls(**data)
|
| 96 |
-
|
| 97 |
-
class GameStorage:
|
| 98 |
-
def __init__(self, storage_dir: Optional[str] = None):
|
| 99 |
-
if storage_dir is None:
|
| 100 |
-
storage_dir = os.path.join(
|
| 101 |
-
os.path.expanduser("~"),
|
| 102 |
-
".battlewords",
|
| 103 |
-
"data"
|
| 104 |
-
)
|
| 105 |
-
self.storage_dir = Path(storage_dir)
|
| 106 |
-
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
| 107 |
-
self.results_file = self.storage_dir / "game_results.json"
|
| 108 |
-
self.highscores_file = self.storage_dir / "highscores.json"
|
| 109 |
-
|
| 110 |
-
def save_result(self, result: GameResult) -> bool:
|
| 111 |
-
try:
|
| 112 |
-
results = self.load_all_results()
|
| 113 |
-
results.append(result.to_dict())
|
| 114 |
-
with open(self.results_file, 'w', encoding='utf-8') as f:
|
| 115 |
-
json.dump(results, f, indent=2, ensure_ascii=False)
|
| 116 |
-
self._update_highscores(result)
|
| 117 |
-
return True
|
| 118 |
-
except Exception as e:
|
| 119 |
-
print(f"Error saving result: {e}")
|
| 120 |
-
return False
|
| 121 |
-
|
| 122 |
-
def load_all_results(self) -> List[Dict[str, Any]]:
|
| 123 |
-
if not self.results_file.exists():
|
| 124 |
-
return []
|
| 125 |
-
try:
|
| 126 |
-
with open(self.results_file, 'r', encoding='utf-8') as f:
|
| 127 |
-
return json.load(f)
|
| 128 |
-
except Exception as e:
|
| 129 |
-
print(f"Error loading results: {e}")
|
| 130 |
-
return []
|
| 131 |
-
|
| 132 |
-
def get_results_by_game_id(self, game_id: str) -> List[GameResult]:
|
| 133 |
-
all_results = self.load_all_results()
|
| 134 |
-
matching = [
|
| 135 |
-
GameResult.from_dict(r)
|
| 136 |
-
for r in all_results
|
| 137 |
-
if r.get("game_id") == game_id
|
| 138 |
-
]
|
| 139 |
-
return sorted(matching, key=lambda x: x.score, reverse=True)
|
| 140 |
-
|
| 141 |
-
def _update_highscores(self, result: GameResult) -> None:
|
| 142 |
-
highscores = self.load_highscores()
|
| 143 |
-
entry = HighScoreEntry(
|
| 144 |
-
player_name=result.player_name or "Anonymous",
|
| 145 |
-
score=result.score,
|
| 146 |
-
tier=result.tier,
|
| 147 |
-
wordlist=result.wordlist,
|
| 148 |
-
game_mode=result.game_mode,
|
| 149 |
-
elapsed_seconds=result.elapsed_seconds,
|
| 150 |
-
completed_at=result.completed_at,
|
| 151 |
-
game_id=result.game_id
|
| 152 |
-
)
|
| 153 |
-
highscores.append(entry.to_dict())
|
| 154 |
-
highscores.sort(key=lambda x: x["score"], reverse=True)
|
| 155 |
-
highscores = highscores[:100]
|
| 156 |
-
with open(self.highscores_file, 'w', encoding='utf-8') as f:
|
| 157 |
-
json.dump(highscores, f, indent=2, ensure_ascii=False)
|
| 158 |
-
|
| 159 |
-
def load_highscores(
|
| 160 |
-
self,
|
| 161 |
-
wordlist: Optional[str] = None,
|
| 162 |
-
game_mode: Optional[str] = None,
|
| 163 |
-
limit: int = 10
|
| 164 |
-
) -> List[HighScoreEntry]:
|
| 165 |
-
if not self.highscores_file.exists():
|
| 166 |
-
return []
|
| 167 |
-
try:
|
| 168 |
-
with open(self.highscores_file, 'r', encoding='utf-8') as f:
|
| 169 |
-
scores = json.load(f)
|
| 170 |
-
if wordlist:
|
| 171 |
-
scores = [s for s in scores if s.get("wordlist") == wordlist]
|
| 172 |
-
if game_mode:
|
| 173 |
-
scores = [s for s in scores if s.get("game_mode") == game_mode]
|
| 174 |
-
scores.sort(key=lambda x: x["score"], reverse=True)
|
| 175 |
-
return [HighScoreEntry.from_dict(s) for s in scores[:limit]]
|
| 176 |
-
except Exception as e:
|
| 177 |
-
print(f"Error loading highscores: {e}")
|
| 178 |
-
return []
|
| 179 |
-
|
| 180 |
-
def get_player_stats(self, player_name: str) -> Dict[str, Any]:
|
| 181 |
-
all_results = self.load_all_results()
|
| 182 |
-
player_results = [
|
| 183 |
-
GameResult.from_dict(r)
|
| 184 |
-
for r in all_results
|
| 185 |
-
if r.get("player_name") == player_name
|
| 186 |
-
]
|
| 187 |
-
if not player_results:
|
| 188 |
-
return {
|
| 189 |
-
"games_played": 0,
|
| 190 |
-
"total_score": 0,
|
| 191 |
-
"average_score": 0,
|
| 192 |
-
"best_score": 0,
|
| 193 |
-
"best_tier": None
|
| 194 |
-
}
|
| 195 |
-
scores = [r.score for r in player_results]
|
| 196 |
-
return {
|
| 197 |
-
"games_played": len(player_results),
|
| 198 |
-
"total_score": sum(scores),
|
| 199 |
-
"average_score": sum(scores) / len(scores),
|
| 200 |
-
"best_score": max(scores),
|
| 201 |
-
"best_tier": max(player_results, key=lambda x: x.score).tier,
|
| 202 |
-
"fastest_time": min(r.elapsed_seconds for r in player_results)
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
def save_json_to_file(data: dict, directory: str, filename: str = "settings.json") -> str:
|
| 206 |
-
"""
|
| 207 |
-
Save a dictionary as a JSON file with a specified filename in the given directory.
|
| 208 |
-
Returns the full path to the saved file.
|
| 209 |
-
"""
|
| 210 |
-
os.makedirs(directory, exist_ok=True)
|
| 211 |
-
file_path = os.path.join(directory, filename)
|
| 212 |
-
with open(file_path, "w", encoding="utf-8") as f:
|
| 213 |
-
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 214 |
-
return file_path
|
| 215 |
-
|
| 216 |
-
def generate_game_id_from_words(words: List[str]) -> str:
|
| 217 |
-
import hashlib
|
| 218 |
-
sorted_words = sorted([w.upper() for w in words])
|
| 219 |
-
word_string = "".join(sorted_words)
|
| 220 |
-
hash_obj = hashlib.sha256(word_string.encode('utf-8'))
|
| 221 |
-
return hash_obj.hexdigest()[:8].upper()
|
| 222 |
-
|
| 223 |
-
def parse_game_id_from_url() -> Optional[str]:
|
| 224 |
-
try:
|
| 225 |
-
import streamlit as st
|
| 226 |
-
params = st.query_params
|
| 227 |
-
return params.get("game_id")
|
| 228 |
-
except Exception:
|
| 229 |
-
return None
|
| 230 |
-
|
| 231 |
-
def create_shareable_url(game_id: str, base_url: Optional[str] = None) -> str:
|
| 232 |
-
if base_url is None:
|
| 233 |
-
base_url = "https://huggingface.co/spaces/Surn/BattleWords"
|
| 234 |
-
return f"{base_url}?game_id={game_id}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
battlewords/modules/__init__.py
CHANGED
|
@@ -23,18 +23,6 @@ from .constants import (
|
|
| 23 |
doc_extensions_list
|
| 24 |
)
|
| 25 |
|
| 26 |
-
from .file_utils import (
|
| 27 |
-
get_file_parts,
|
| 28 |
-
rename_file_to_lowercase_extension,
|
| 29 |
-
get_filename,
|
| 30 |
-
convert_title_to_filename,
|
| 31 |
-
get_filename_from_filepath,
|
| 32 |
-
delete_file,
|
| 33 |
-
get_unique_file_path,
|
| 34 |
-
download_and_save_image,
|
| 35 |
-
download_and_save_file
|
| 36 |
-
)
|
| 37 |
-
|
| 38 |
__all__ = [
|
| 39 |
# constants.py
|
| 40 |
'APP_SETTINGS',
|
|
|
|
| 23 |
doc_extensions_list
|
| 24 |
)
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
__all__ = [
|
| 27 |
# constants.py
|
| 28 |
'APP_SETTINGS',
|
battlewords/modules/file_utils.py
DELETED
|
@@ -1,204 +0,0 @@
|
|
| 1 |
-
# file_utils
|
| 2 |
-
import os
|
| 3 |
-
import shutil
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
import requests
|
| 6 |
-
from PIL import Image
|
| 7 |
-
from io import BytesIO
|
| 8 |
-
from urllib.parse import urlparse
|
| 9 |
-
|
| 10 |
-
def get_file_parts(file_path: str):
|
| 11 |
-
# Split the path into directory and filename
|
| 12 |
-
directory, filename = os.path.split(file_path)
|
| 13 |
-
|
| 14 |
-
# Split the filename into name and extension
|
| 15 |
-
name, ext = os.path.splitext(filename)
|
| 16 |
-
|
| 17 |
-
# Convert the extension to lowercase
|
| 18 |
-
new_ext = ext.lower()
|
| 19 |
-
return directory, filename, name, ext, new_ext
|
| 20 |
-
|
| 21 |
-
def rename_file_to_lowercase_extension(file_path: str) -> str:
|
| 22 |
-
"""
|
| 23 |
-
Renames a file's extension to lowercase in place.
|
| 24 |
-
|
| 25 |
-
Parameters:
|
| 26 |
-
file_path (str): The original file path.
|
| 27 |
-
|
| 28 |
-
Returns:
|
| 29 |
-
str: The new file path with the lowercase extension.
|
| 30 |
-
|
| 31 |
-
Raises:
|
| 32 |
-
OSError: If there is an error renaming the file (e.g., file not found, permissions issue).
|
| 33 |
-
"""
|
| 34 |
-
directory, filename, name, ext, new_ext = get_file_parts(file_path)
|
| 35 |
-
# If the extension changes, rename the file
|
| 36 |
-
if ext != new_ext:
|
| 37 |
-
new_filename = name + new_ext
|
| 38 |
-
new_file_path = os.path.join(directory, new_filename)
|
| 39 |
-
try:
|
| 40 |
-
os.rename(file_path, new_file_path)
|
| 41 |
-
print(f"Rename {file_path} to {new_file_path}\n")
|
| 42 |
-
except Exception as e:
|
| 43 |
-
print(f"os.rename failed: {e}. Falling back to binary copy operation.")
|
| 44 |
-
try:
|
| 45 |
-
# Read the file in binary mode and write it to new_file_path
|
| 46 |
-
with open(file_path, 'rb') as f:
|
| 47 |
-
data = f.read()
|
| 48 |
-
with open(new_file_path, 'wb') as f:
|
| 49 |
-
f.write(data)
|
| 50 |
-
print(f"Copied {file_path} to {new_file_path}\n")
|
| 51 |
-
# Optionally, remove the original file after copying
|
| 52 |
-
#os.remove(file_path)
|
| 53 |
-
except Exception as inner_e:
|
| 54 |
-
print(f"Failed to copy file from {file_path} to {new_file_path}: {inner_e}")
|
| 55 |
-
raise inner_e
|
| 56 |
-
return new_file_path
|
| 57 |
-
else:
|
| 58 |
-
return file_path
|
| 59 |
-
|
| 60 |
-
def get_filename(file):
|
| 61 |
-
# extract filename from file object
|
| 62 |
-
filename = None
|
| 63 |
-
if file is not None:
|
| 64 |
-
filename = file.name
|
| 65 |
-
return filename
|
| 66 |
-
|
| 67 |
-
def convert_title_to_filename(title):
|
| 68 |
-
# convert title to filename
|
| 69 |
-
filename = title.lower().replace(" ", "_").replace("/", "_")
|
| 70 |
-
return filename
|
| 71 |
-
|
| 72 |
-
def get_filename_from_filepath(filepath):
|
| 73 |
-
file_name = os.path.basename(filepath)
|
| 74 |
-
file_base, file_extension = os.path.splitext(file_name)
|
| 75 |
-
return file_base, file_extension
|
| 76 |
-
|
| 77 |
-
def delete_file(file_path: str) -> None:
|
| 78 |
-
"""
|
| 79 |
-
Deletes the specified file.
|
| 80 |
-
|
| 81 |
-
Parameters:
|
| 82 |
-
file_path (str): The path to thefile to delete.
|
| 83 |
-
|
| 84 |
-
Raises:
|
| 85 |
-
FileNotFoundError: If the file does not exist.
|
| 86 |
-
Exception: If there is an error deleting the file.
|
| 87 |
-
"""
|
| 88 |
-
try:
|
| 89 |
-
path = Path(file_path)
|
| 90 |
-
path.unlink()
|
| 91 |
-
print(f"Deleted original file: {file_path}")
|
| 92 |
-
except FileNotFoundError:
|
| 93 |
-
print(f"File not found: {file_path}")
|
| 94 |
-
except Exception as e:
|
| 95 |
-
print(f"Error deleting file: {e}")
|
| 96 |
-
|
| 97 |
-
def get_unique_file_path(directory, filename, file_ext, counter=0):
|
| 98 |
-
"""
|
| 99 |
-
Recursively increments the filename until a unique path is found.
|
| 100 |
-
|
| 101 |
-
Parameters:
|
| 102 |
-
directory (str): The directory for the file.
|
| 103 |
-
filename (str): The base filename.
|
| 104 |
-
file_ext (str): The file extension including the leading dot.
|
| 105 |
-
counter (int): The current counter value to append.
|
| 106 |
-
|
| 107 |
-
Returns:
|
| 108 |
-
str: A unique file path that does not exist.
|
| 109 |
-
"""
|
| 110 |
-
if counter == 0:
|
| 111 |
-
filepath = os.path.join(directory, f"{filename}{file_ext}")
|
| 112 |
-
else:
|
| 113 |
-
filepath = os.path.join(directory, f"{filename}{counter}{file_ext}")
|
| 114 |
-
|
| 115 |
-
if not os.path.exists(filepath):
|
| 116 |
-
return filepath
|
| 117 |
-
else:
|
| 118 |
-
return get_unique_file_path(directory, filename, file_ext, counter + 1)
|
| 119 |
-
|
| 120 |
-
# Example usage:
|
| 121 |
-
# new_file_path = get_unique_file_path(video_dir, title_file_name, video_new_ext)
|
| 122 |
-
|
| 123 |
-
def download_and_save_image(url: str, dst_folder: Path, token: str = None) -> Path:
|
| 124 |
-
"""
|
| 125 |
-
Downloads an image from a URL with authentication if a token is provided,
|
| 126 |
-
verifies it with PIL, and saves it in dst_folder with a unique filename.
|
| 127 |
-
|
| 128 |
-
Args:
|
| 129 |
-
url (str): The image URL.
|
| 130 |
-
dst_folder (Path): The destination folder for the image.
|
| 131 |
-
token (str, optional): A valid Bearer token. If not provided, the HF_API_TOKEN
|
| 132 |
-
environment variable is used if available.
|
| 133 |
-
|
| 134 |
-
Returns:
|
| 135 |
-
Path: The saved image's file path.
|
| 136 |
-
"""
|
| 137 |
-
headers = {}
|
| 138 |
-
# Use provided token; otherwise, fall back to environment variable.
|
| 139 |
-
api_token = token
|
| 140 |
-
if api_token:
|
| 141 |
-
headers["Authorization"] = f"Bearer {api_token}"
|
| 142 |
-
|
| 143 |
-
response = requests.get(url, headers=headers)
|
| 144 |
-
response.raise_for_status()
|
| 145 |
-
pil_image = Image.open(BytesIO(response.content))
|
| 146 |
-
|
| 147 |
-
parsed_url = urlparse(url)
|
| 148 |
-
original_filename = os.path.basename(parsed_url.path) # e.g., "background.png"
|
| 149 |
-
base, ext = os.path.splitext(original_filename)
|
| 150 |
-
|
| 151 |
-
# Use get_unique_file_path from file_utils.py to generate a unique file path.
|
| 152 |
-
unique_filepath_str = get_unique_file_path(str(dst_folder), base, ext)
|
| 153 |
-
dst = Path(unique_filepath_str)
|
| 154 |
-
dst_folder.mkdir(parents=True, exist_ok=True)
|
| 155 |
-
pil_image.save(dst)
|
| 156 |
-
return dst
|
| 157 |
-
|
| 158 |
-
def download_and_save_file(url: str, dst_folder: Path, token: str = None) -> Path:
|
| 159 |
-
"""
|
| 160 |
-
Downloads a binary file (e.g., audio or video) from a URL with authentication if a token is provided,
|
| 161 |
-
and saves it in dst_folder with a unique filename.
|
| 162 |
-
|
| 163 |
-
Args:
|
| 164 |
-
url (str): The file URL.
|
| 165 |
-
dst_folder (Path): The destination folder for the file.
|
| 166 |
-
token (str, optional): A valid Bearer token.
|
| 167 |
-
|
| 168 |
-
Returns:
|
| 169 |
-
Path: The saved file's path.
|
| 170 |
-
"""
|
| 171 |
-
headers = {}
|
| 172 |
-
if token:
|
| 173 |
-
headers["Authorization"] = f"Bearer {token}"
|
| 174 |
-
|
| 175 |
-
response = requests.get(url, headers=headers)
|
| 176 |
-
response.raise_for_status()
|
| 177 |
-
|
| 178 |
-
parsed_url = urlparse(url)
|
| 179 |
-
original_filename = os.path.basename(parsed_url.path)
|
| 180 |
-
base, ext = os.path.splitext(original_filename)
|
| 181 |
-
|
| 182 |
-
unique_filepath_str = get_unique_file_path(str(dst_folder), base, ext)
|
| 183 |
-
dst = Path(unique_filepath_str)
|
| 184 |
-
dst_folder.mkdir(parents=True, exist_ok=True)
|
| 185 |
-
|
| 186 |
-
with open(dst, "wb") as f:
|
| 187 |
-
f.write(response.content)
|
| 188 |
-
|
| 189 |
-
return dst
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
if __name__ == "__main__":
|
| 193 |
-
# Example usage
|
| 194 |
-
url = "https://example.com/image.png"
|
| 195 |
-
dst_folder = Path("downloads")
|
| 196 |
-
download_and_save_image(url, dst_folder)
|
| 197 |
-
# Example usage for file download
|
| 198 |
-
file_url = "https://example.com/file.mp3"
|
| 199 |
-
downloaded_file = download_and_save_file(file_url, dst_folder)
|
| 200 |
-
print(f"File downloaded to: {downloaded_file}")
|
| 201 |
-
# Example usage for renaming file extension
|
| 202 |
-
file_path = "example.TXT"
|
| 203 |
-
new_file_path = rename_file_to_lowercase_extension(file_path)
|
| 204 |
-
print(f"Renamed file to: {new_file_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
battlewords/settings_page.py
DELETED
|
@@ -1,304 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
-
import time
|
| 5 |
-
from typing import Any, Callable, Dict
|
| 6 |
-
|
| 7 |
-
import streamlit as st
|
| 8 |
-
|
| 9 |
-
from .local_storage import load_latest_settings, save_active_settings
|
| 10 |
-
from battlewords.modules.version_info import versions_html # version info footer
|
| 11 |
-
from .generator import sort_word_file, filter_word_file
|
| 12 |
-
from .audio import get_audio_tracks, _inject_audio_control_sync, get_sound_effect_files
|
| 13 |
-
from .generator import sort_word_file
|
| 14 |
-
from .word_loader import get_wordlist_files
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
_PERSISTED_SETTING_KEYS: tuple[str, ...] = (
|
| 18 |
-
"game_mode",
|
| 19 |
-
"selected_wordlist",
|
| 20 |
-
"show_grid_ticks",
|
| 21 |
-
"spacer",
|
| 22 |
-
"show_incorrect_guesses",
|
| 23 |
-
"show_challenge_share_links",
|
| 24 |
-
"music_enabled",
|
| 25 |
-
"music_volume",
|
| 26 |
-
"effects_volume",
|
| 27 |
-
"enable_sound_effects",
|
| 28 |
-
"music_track_path",
|
| 29 |
-
"user_id",
|
| 30 |
-
"subscription_level",
|
| 31 |
-
"player_name",
|
| 32 |
-
)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _load_settings_into_session(settings: Dict[str, Any]) -> bool:
|
| 36 |
-
"""Load a settings dict into `st.session_state`.
|
| 37 |
-
|
| 38 |
-
Args:
|
| 39 |
-
settings: Settings values.
|
| 40 |
-
|
| 41 |
-
Returns:
|
| 42 |
-
bool: True if any session value changed.
|
| 43 |
-
"""
|
| 44 |
-
changed = False
|
| 45 |
-
for key, value in settings.items():
|
| 46 |
-
if st.session_state.get(key) != value:
|
| 47 |
-
st.session_state[key] = value
|
| 48 |
-
changed = True
|
| 49 |
-
return changed
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def _sort_wordlist(filename: str, new_game_callback: Callable[[], None]) -> None:
|
| 53 |
-
|
| 54 |
-
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 55 |
-
filepath = os.path.join(words_dir, filename)
|
| 56 |
-
sorted_words = sort_word_file(filepath)
|
| 57 |
-
|
| 58 |
-
with open(filepath, "w", encoding="utf-8") as f:
|
| 59 |
-
f.write("# Optional: place a large A-Z word list here (one word per line).\n")
|
| 60 |
-
f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
|
| 61 |
-
for word in sorted_words:
|
| 62 |
-
f.write(f"{word}\n")
|
| 63 |
-
|
| 64 |
-
st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...")
|
| 65 |
-
time.sleep(5)
|
| 66 |
-
new_game_callback()
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def _filter_wordlist(filename: str) -> None:
|
| 70 |
-
words_dir = os.path.join(os.path.dirname(__file__), "words")
|
| 71 |
-
filepath = os.path.join(words_dir, filename)
|
| 72 |
-
|
| 73 |
-
try:
|
| 74 |
-
with open(filepath, "r", encoding="utf-8") as f:
|
| 75 |
-
lines = f.readlines()
|
| 76 |
-
except Exception as exc:
|
| 77 |
-
st.error(f"Failed to read wordlist: {exc}")
|
| 78 |
-
return
|
| 79 |
-
|
| 80 |
-
header_lines = [ln for ln in lines if ln.strip().startswith("#")]
|
| 81 |
-
word_lines = [ln.strip() for ln in lines if ln.strip() and not ln.strip().startswith("#")]
|
| 82 |
-
|
| 83 |
-
# Keep only A-Z words, uppercase, lengths 4-6; dedupe while preserving order
|
| 84 |
-
kept: list[str] = []
|
| 85 |
-
seen: set[str] = set()
|
| 86 |
-
removed_count = 0
|
| 87 |
-
for raw in word_lines:
|
| 88 |
-
w = raw.strip().upper()
|
| 89 |
-
if not w.isalpha() or not w.isascii():
|
| 90 |
-
removed_count += 1
|
| 91 |
-
continue
|
| 92 |
-
if len(w) not in (4, 5, 6):
|
| 93 |
-
removed_count += 1
|
| 94 |
-
continue
|
| 95 |
-
if w in seen:
|
| 96 |
-
removed_count += 1
|
| 97 |
-
continue
|
| 98 |
-
seen.add(w)
|
| 99 |
-
kept.append(w)
|
| 100 |
-
|
| 101 |
-
with open(filepath, "w", encoding="utf-8") as f:
|
| 102 |
-
if header_lines:
|
| 103 |
-
for ln in header_lines:
|
| 104 |
-
f.write(ln if ln.endswith("\n") else ln + "\n")
|
| 105 |
-
else:
|
| 106 |
-
f.write("# Optional: place a large A-Z word list here (one word per line).\n")
|
| 107 |
-
f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
|
| 108 |
-
for w in kept:
|
| 109 |
-
f.write(f"{w}\n")
|
| 110 |
-
|
| 111 |
-
st.success(
|
| 112 |
-
f"Filtered wordlist '{filename}': removed {removed_count} invalid/duplicate/out-of-range entries."
|
| 113 |
-
)
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def render_settings_page(new_game_callback: Callable[[], None]) -> None:
|
| 117 |
-
"""Render the Settings page.
|
| 118 |
-
|
| 119 |
-
This is a minimal port of Wrdler's dedicated settings page concept.
|
| 120 |
-
BattleWords previously used sidebar controls; this page persists preferences
|
| 121 |
-
locally and applies them by starting a new game.
|
| 122 |
-
|
| 123 |
-
Args:
|
| 124 |
-
new_game_callback: Callback that clears challenge state (if any) and
|
| 125 |
-
starts a new game with the updated settings.
|
| 126 |
-
"""
|
| 127 |
-
st.markdown('<div id="settings"></div>', unsafe_allow_html=True)
|
| 128 |
-
st.header("Settings")
|
| 129 |
-
|
| 130 |
-
if not st.session_state.get("_settings_first_load", False):
|
| 131 |
-
latest = load_latest_settings()
|
| 132 |
-
if latest:
|
| 133 |
-
if _load_settings_into_session(latest):
|
| 134 |
-
st.session_state["_settings_first_load"] = True
|
| 135 |
-
st.rerun()
|
| 136 |
-
st.session_state["_settings_first_load"] = True
|
| 137 |
-
|
| 138 |
-
st.subheader("Game")
|
| 139 |
-
|
| 140 |
-
game_modes = ["classic", "too easy"]
|
| 141 |
-
if "game_mode" not in st.session_state:
|
| 142 |
-
st.session_state.game_mode = "classic"
|
| 143 |
-
|
| 144 |
-
st.selectbox(
|
| 145 |
-
"Game mode",
|
| 146 |
-
options=game_modes,
|
| 147 |
-
index=game_modes.index(st.session_state.game_mode)
|
| 148 |
-
if st.session_state.game_mode in game_modes
|
| 149 |
-
else 0,
|
| 150 |
-
key="game_mode",
|
| 151 |
-
)
|
| 152 |
-
|
| 153 |
-
st.subheader("Wordlist Controls")
|
| 154 |
-
|
| 155 |
-
files = get_wordlist_files()
|
| 156 |
-
if files and "selected_wordlist" not in st.session_state:
|
| 157 |
-
st.session_state.selected_wordlist = "classic.txt" if "classic.txt" in files else files[0]
|
| 158 |
-
|
| 159 |
-
if files:
|
| 160 |
-
st.selectbox(
|
| 161 |
-
"Select wordlist",
|
| 162 |
-
options=files,
|
| 163 |
-
index=files.index(st.session_state.selected_wordlist)
|
| 164 |
-
if st.session_state.selected_wordlist in files
|
| 165 |
-
else 0,
|
| 166 |
-
key="selected_wordlist",
|
| 167 |
-
)
|
| 168 |
-
|
| 169 |
-
col_sort, col_filter = st.columns(2)
|
| 170 |
-
with col_sort:
|
| 171 |
-
if st.button("Sort Wordlist", key="sort_wordlist_btn", width=125):
|
| 172 |
-
_sort_wordlist(st.session_state.selected_wordlist, new_game_callback)
|
| 173 |
-
with col_filter:
|
| 174 |
-
if st.button("Filter Wordlist", key="filter_wordlist_btn", width=125):
|
| 175 |
-
_filter_wordlist(st.session_state.selected_wordlist)
|
| 176 |
-
else:
|
| 177 |
-
st.info("No word lists found in words/ directory.")
|
| 178 |
-
|
| 179 |
-
if "show_grid_ticks" not in st.session_state:
|
| 180 |
-
st.session_state.show_grid_ticks = False
|
| 181 |
-
st.checkbox("Show grid ticks", key="show_grid_ticks")
|
| 182 |
-
|
| 183 |
-
spacer_options = [0, 1, 2]
|
| 184 |
-
if "spacer" not in st.session_state:
|
| 185 |
-
st.session_state.spacer = 1
|
| 186 |
-
st.selectbox(
|
| 187 |
-
"Spacer (space between words)",
|
| 188 |
-
options=spacer_options,
|
| 189 |
-
index=spacer_options.index(st.session_state.spacer)
|
| 190 |
-
if st.session_state.spacer in spacer_options
|
| 191 |
-
else 1,
|
| 192 |
-
key="spacer",
|
| 193 |
-
)
|
| 194 |
-
|
| 195 |
-
if "show_incorrect_guesses" not in st.session_state:
|
| 196 |
-
st.session_state.show_incorrect_guesses = True
|
| 197 |
-
st.checkbox("Show incorrect guesses", key="show_incorrect_guesses")
|
| 198 |
-
|
| 199 |
-
if "show_challenge_share_links" not in st.session_state:
|
| 200 |
-
st.session_state.show_challenge_share_links = False
|
| 201 |
-
st.checkbox("Show challenge share links", key="show_challenge_share_links")
|
| 202 |
-
|
| 203 |
-
# Audio settings
|
| 204 |
-
st.header("Audio")
|
| 205 |
-
|
| 206 |
-
# --- List sound effects in effects folder ---
|
| 207 |
-
effects = get_sound_effect_files()
|
| 208 |
-
if effects:
|
| 209 |
-
effect_list = ", ".join(sorted(effects.keys()))
|
| 210 |
-
st.caption(f"Sound effects found in wrdler/assets/audio/effects: {effect_list}")
|
| 211 |
-
else:
|
| 212 |
-
st.caption("No sound effects found in wrdler/assets/audio/effects.")
|
| 213 |
-
|
| 214 |
-
if "music_enabled" not in st.session_state:
|
| 215 |
-
st.session_state.music_enabled = False
|
| 216 |
-
if "music_volume" not in st.session_state:
|
| 217 |
-
st.session_state.music_volume = 15
|
| 218 |
-
|
| 219 |
-
if "enable_sound_effects" not in st.session_state:
|
| 220 |
-
st.session_state.enable_sound_effects = True
|
| 221 |
-
st.checkbox("Enable sound effects", key="enable_sound_effects")
|
| 222 |
-
|
| 223 |
-
if "effects_volume" not in st.session_state:
|
| 224 |
-
st.session_state.effects_volume = 25
|
| 225 |
-
st.slider(
|
| 226 |
-
"Sound effects volume",
|
| 227 |
-
0,
|
| 228 |
-
100,
|
| 229 |
-
value=int(st.session_state.effects_volume),
|
| 230 |
-
step=1,
|
| 231 |
-
key="effects_volume",
|
| 232 |
-
)
|
| 233 |
-
|
| 234 |
-
tracks = get_audio_tracks()
|
| 235 |
-
st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in wrdler/assets/audio/music")
|
| 236 |
-
|
| 237 |
-
enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
|
| 238 |
-
|
| 239 |
-
st.slider(
|
| 240 |
-
"Volume",
|
| 241 |
-
0,
|
| 242 |
-
100,
|
| 243 |
-
value=int(st.session_state.music_volume),
|
| 244 |
-
step=1,
|
| 245 |
-
key="music_volume",
|
| 246 |
-
disabled=not (enabled and bool(tracks)),
|
| 247 |
-
)
|
| 248 |
-
|
| 249 |
-
selected_path = None
|
| 250 |
-
if tracks:
|
| 251 |
-
options = [p for _, p in tracks]
|
| 252 |
-
# Default to first track if none chosen yet
|
| 253 |
-
if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options:
|
| 254 |
-
st.session_state.music_track_path = options[0]
|
| 255 |
-
|
| 256 |
-
def _fmt(p: str) -> str:
|
| 257 |
-
# Find friendly label for path
|
| 258 |
-
for name, path in tracks:
|
| 259 |
-
if path == p:
|
| 260 |
-
return name
|
| 261 |
-
return os.path.splitext(os.path.basename(p))[0]
|
| 262 |
-
|
| 263 |
-
selected_path = st.selectbox(
|
| 264 |
-
"Track",
|
| 265 |
-
options=options,
|
| 266 |
-
index=options.index(st.session_state.music_track_path),
|
| 267 |
-
format_func=_fmt,
|
| 268 |
-
key="music_track_path",
|
| 269 |
-
disabled=not enabled,
|
| 270 |
-
)
|
| 271 |
-
else:
|
| 272 |
-
st.caption("Place .mp3 files in wrdler/assets/audio/music to enable music.")
|
| 273 |
-
|
| 274 |
-
st.markdown("---")
|
| 275 |
-
|
| 276 |
-
col_apply, col_reset = st.columns(2)
|
| 277 |
-
|
| 278 |
-
with col_apply:
|
| 279 |
-
if st.button("Save & Apply", key="settings_save_apply_btn", width="stretch"):
|
| 280 |
-
snapshot = {key: st.session_state.get(key) for key in _PERSISTED_SETTING_KEYS}
|
| 281 |
-
save_active_settings(snapshot)
|
| 282 |
-
st.session_state["_settings_saved_notice"] = "Settings saved."
|
| 283 |
-
new_game_callback()
|
| 284 |
-
try:
|
| 285 |
-
st.query_params["page"] = "play"
|
| 286 |
-
except Exception:
|
| 287 |
-
pass
|
| 288 |
-
st.rerun()
|
| 289 |
-
|
| 290 |
-
with col_reset:
|
| 291 |
-
if st.button("Discard changes", key="settings_discard_btn", width="stretch"):
|
| 292 |
-
latest = load_latest_settings()
|
| 293 |
-
if latest:
|
| 294 |
-
_load_settings_into_session(latest)
|
| 295 |
-
st.rerun()
|
| 296 |
-
|
| 297 |
-
notice = st.session_state.pop("_settings_saved_notice", None)
|
| 298 |
-
if notice:
|
| 299 |
-
st.success(notice)
|
| 300 |
-
|
| 301 |
-
settings_dir = os.path.join(os.path.dirname(__file__), "settings")
|
| 302 |
-
st.caption(f"Settings are stored locally under: {settings_dir}")
|
| 303 |
-
|
| 304 |
-
st.markdown(versions_html(), unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
battlewords/sounds.py
DELETED
|
@@ -1,195 +0,0 @@
|
|
| 1 |
-
# file: battlewords/sounds.py
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
-
import tempfile
|
| 5 |
-
import base64
|
| 6 |
-
import requests
|
| 7 |
-
import time
|
| 8 |
-
from io import BytesIO
|
| 9 |
-
from pathlib import Path
|
| 10 |
-
|
| 11 |
-
# Load environment variables from .env file
|
| 12 |
-
def _load_env():
|
| 13 |
-
"""Load .env file from project root"""
|
| 14 |
-
env_path = Path(__file__).parent.parent / ".env"
|
| 15 |
-
if env_path.exists():
|
| 16 |
-
with open(env_path) as f:
|
| 17 |
-
for line in f:
|
| 18 |
-
line = line.strip()
|
| 19 |
-
if line and not line.startswith("#") and "=" in line:
|
| 20 |
-
key, value = line.split("=", 1)
|
| 21 |
-
os.environ.setdefault(key.strip(), value.strip())
|
| 22 |
-
|
| 23 |
-
_load_env()
|
| 24 |
-
|
| 25 |
-
# Predefined prompts for sound effects: key: {"prompt": text, "duration": seconds}
|
| 26 |
-
EFFECT_PROMPTS = {
|
| 27 |
-
"correct_guess": {"prompt": "A short, sharp ding sound for a correct guess", "duration": 2},
|
| 28 |
-
"incorrect_guess": {"prompt": "A low buzz sound for an incorrect guess", "duration": 2},
|
| 29 |
-
"miss": {"prompt": "A soft thud sound for a miss", "duration": 1},
|
| 30 |
-
"hit": {"prompt": "A bright chime sound for a hit", "duration": 1},
|
| 31 |
-
"congratulations": {"prompt": "A triumphant fanfare sound for congratulations", "duration": 3}
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
_sound_cache = {}
|
| 35 |
-
|
| 36 |
-
# Hugging Face Inference API configuration
|
| 37 |
-
# Loaded from .env file or environment variable: HF_API_TOKEN
|
| 38 |
-
HF_API_TOKEN = os.environ.get("HF_API_TOKEN", None)
|
| 39 |
-
if HF_API_TOKEN:
|
| 40 |
-
print(f"Using HF_API_TOKEN: {HF_API_TOKEN[:10]}...")
|
| 41 |
-
HF_API_URL = "https://api-inference.huggingface.co/models/facebook/audiogen-medium"
|
| 42 |
-
|
| 43 |
-
def generate_sound_effect(effect: str, save_to_assets: bool = False, use_api: str = "huggingface") -> str:
|
| 44 |
-
"""
|
| 45 |
-
Generate a sound effect using external API based on the effect string.
|
| 46 |
-
Returns the path to the generated audio file.
|
| 47 |
-
|
| 48 |
-
Args:
|
| 49 |
-
effect: Name of the effect (must be in EFFECT_PROMPTS)
|
| 50 |
-
save_to_assets: If True, save to battlewords/assets/audio/effects/ instead of temp directory
|
| 51 |
-
use_api: API to use - "huggingface" (default) or "replicate"
|
| 52 |
-
"""
|
| 53 |
-
if effect not in EFFECT_PROMPTS:
|
| 54 |
-
raise ValueError(f"Unknown effect: {effect}. Available effects: {list(EFFECT_PROMPTS.keys())}")
|
| 55 |
-
|
| 56 |
-
# Check cache first (only for temp files)
|
| 57 |
-
if effect in _sound_cache and not save_to_assets:
|
| 58 |
-
if os.path.exists(_sound_cache[effect]):
|
| 59 |
-
return _sound_cache[effect]
|
| 60 |
-
|
| 61 |
-
effect_config = EFFECT_PROMPTS[effect]
|
| 62 |
-
prompt = effect_config["prompt"]
|
| 63 |
-
duration = effect_config["duration"]
|
| 64 |
-
|
| 65 |
-
print(f"Generating sound effect: {effect}")
|
| 66 |
-
print(f" Prompt: {prompt}")
|
| 67 |
-
print(f" Duration: {duration}s")
|
| 68 |
-
print(f" Using API: {use_api}")
|
| 69 |
-
|
| 70 |
-
audio_bytes = None
|
| 71 |
-
|
| 72 |
-
if use_api == "huggingface":
|
| 73 |
-
audio_bytes = _generate_via_huggingface(prompt, duration)
|
| 74 |
-
else:
|
| 75 |
-
raise ValueError(f"Unknown API: {use_api}")
|
| 76 |
-
|
| 77 |
-
if audio_bytes is None:
|
| 78 |
-
raise RuntimeError(f"Failed to generate sound effect: {effect}")
|
| 79 |
-
|
| 80 |
-
# Determine save location
|
| 81 |
-
if save_to_assets:
|
| 82 |
-
# Save to effects assets directory (preferred by audio.py)
|
| 83 |
-
assets_dir = os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
|
| 84 |
-
os.makedirs(assets_dir, exist_ok=True)
|
| 85 |
-
filename = f"{effect}.wav"
|
| 86 |
-
path = os.path.join(assets_dir, filename)
|
| 87 |
-
with open(path, "wb") as f:
|
| 88 |
-
f.write(audio_bytes)
|
| 89 |
-
print(f" Saved to: {path}")
|
| 90 |
-
else:
|
| 91 |
-
# Save to temporary file
|
| 92 |
-
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpfile:
|
| 93 |
-
tmpfile.write(audio_bytes)
|
| 94 |
-
path = tmpfile.name
|
| 95 |
-
print(f" Saved to: {path}")
|
| 96 |
-
|
| 97 |
-
# Cache the path
|
| 98 |
-
_sound_cache[effect] = path
|
| 99 |
-
return path
|
| 100 |
-
|
| 101 |
-
def _generate_via_huggingface(prompt: str, duration: float, max_retries: int = 3) -> bytes:
|
| 102 |
-
"""
|
| 103 |
-
Generate audio using Hugging Face Inference API.
|
| 104 |
-
Uses facebook/audiogen-medium model for sound effects.
|
| 105 |
-
"""
|
| 106 |
-
headers = {}
|
| 107 |
-
if HF_API_TOKEN:
|
| 108 |
-
headers["Authorization"] = f"Bearer {HF_API_TOKEN}"
|
| 109 |
-
|
| 110 |
-
payload = {
|
| 111 |
-
"inputs": prompt,
|
| 112 |
-
"parameters": {
|
| 113 |
-
"duration": duration
|
| 114 |
-
}
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
for attempt in range(max_retries):
|
| 118 |
-
try:
|
| 119 |
-
print(f" Calling Hugging Face API (attempt {attempt + 1}/{max_retries})...")
|
| 120 |
-
response = requests.post(HF_API_URL, headers=headers, json=payload, timeout=60)
|
| 121 |
-
|
| 122 |
-
if response.status_code == 503:
|
| 123 |
-
# Model is loading, wait and retry
|
| 124 |
-
print(f" Model loading, waiting 10 seconds...")
|
| 125 |
-
time.sleep(10)
|
| 126 |
-
continue
|
| 127 |
-
|
| 128 |
-
if response.status_code == 200:
|
| 129 |
-
print(f" Success! Received {len(response.content)} bytes")
|
| 130 |
-
return response.content
|
| 131 |
-
else:
|
| 132 |
-
print(f" Error {response.status_code}: {response.text}")
|
| 133 |
-
if attempt < max_retries - 1:
|
| 134 |
-
time.sleep(5)
|
| 135 |
-
continue
|
| 136 |
-
else:
|
| 137 |
-
raise RuntimeError(f"API request failed: {response.status_code} - {response.text}")
|
| 138 |
-
|
| 139 |
-
except requests.exceptions.Timeout:
|
| 140 |
-
print(f" Request timed out")
|
| 141 |
-
if attempt < max_retries - 1:
|
| 142 |
-
time.sleep(5)
|
| 143 |
-
continue
|
| 144 |
-
else:
|
| 145 |
-
raise RuntimeError("API request timed out after multiple attempts")
|
| 146 |
-
except Exception as e:
|
| 147 |
-
print(f" Error: {e}")
|
| 148 |
-
if attempt < max_retries - 1:
|
| 149 |
-
time.sleep(5)
|
| 150 |
-
continue
|
| 151 |
-
else:
|
| 152 |
-
raise
|
| 153 |
-
|
| 154 |
-
return None
|
| 155 |
-
|
| 156 |
-
def get_sound_effect_path(effect: str) -> str:
|
| 157 |
-
"""
|
| 158 |
-
Get the path to a sound effect, generating it if necessary.
|
| 159 |
-
"""
|
| 160 |
-
return generate_sound_effect(effect)
|
| 161 |
-
|
| 162 |
-
def get_sound_effect_data_url(effect: str) -> str:
|
| 163 |
-
"""
|
| 164 |
-
Get a data URL for the sound effect, suitable for embedding in HTML.
|
| 165 |
-
"""
|
| 166 |
-
path = generate_sound_effect(effect)
|
| 167 |
-
with open(path, "rb") as f:
|
| 168 |
-
data = f.read()
|
| 169 |
-
encoded = base64.b64encode(data).decode()
|
| 170 |
-
return f"data:audio/wav;base64,{encoded}"
|
| 171 |
-
|
| 172 |
-
def generate_all_effects(save_to_assets: bool = True):
|
| 173 |
-
"""
|
| 174 |
-
Generate all sound effects defined in EFFECT_PROMPTS.
|
| 175 |
-
|
| 176 |
-
Args:
|
| 177 |
-
save_to_assets: If True, save to battlewords/assets/audio/effects/ directory
|
| 178 |
-
"""
|
| 179 |
-
print(f"\nGenerating {len(EFFECT_PROMPTS)} sound effects...")
|
| 180 |
-
print("=" * 60)
|
| 181 |
-
|
| 182 |
-
for effect_name in EFFECT_PROMPTS.keys():
|
| 183 |
-
try:
|
| 184 |
-
generate_sound_effect(effect_name, save_to_assets=save_to_assets)
|
| 185 |
-
print()
|
| 186 |
-
except Exception as e:
|
| 187 |
-
print(f"ERROR generating {effect_name}: {e}")
|
| 188 |
-
print()
|
| 189 |
-
|
| 190 |
-
print("=" * 60)
|
| 191 |
-
print("Sound effect generation complete!")
|
| 192 |
-
|
| 193 |
-
if __name__ == "__main__":
|
| 194 |
-
# Generate all sound effects when run as a script
|
| 195 |
-
generate_all_effects(save_to_assets=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|