Surn commited on
Commit
df83d00
·
1 Parent(s): c60fa59

Basic Only version step 3

Browse files

removed additional dead code

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)