Surn commited on
Commit
1dcc163
Β·
1 Parent(s): a94fc8f

v0.2.8 - add sound effects, incorrect guess list

Browse files
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.mp3 filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.mp3 filter=lfs diff=lfs merge=lfs -text
37
+ *.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -37,6 +37,7 @@ BattleWords is a vocabulary learning game inspired by classic Battleship mechani
37
  - Leaderboards, persistence, and advanced features (Full)
38
  - **Dockerfile-based deployment supported for Hugging Face Spaces and other container platforms**
39
  - **Game ends when all words are guessed or all word letters are revealed**
 
40
 
41
  ## Installation
42
  1. Clone the repository:
@@ -105,6 +106,15 @@ docker run -p 8501:8501 battlewords
105
 
106
  ## Changelog
107
 
 
 
 
 
 
 
 
 
 
108
  -0.2.6
109
  - fix sonar grid alignment
110
  - improve score summary layout and styling
 
37
  - Leaderboards, persistence, and advanced features (Full)
38
  - **Dockerfile-based deployment supported for Hugging Face Spaces and other container platforms**
39
  - **Game ends when all words are guessed or all word letters are revealed**
40
+ - **Incorrect guess history with tooltip and optional display (enabled by default)**
41
 
42
  ## Installation
43
  1. Clone the repository:
 
106
 
107
  ## Changelog
108
 
109
+ -0.2.8
110
+ - Add 10 incorrect guess limit per game
111
+
112
+ -0.2.7
113
+ - fix background music playback issue on some browsers
114
+ - add sound effects
115
+ - enhance sonar grid visualization
116
+ - add claude.md documentation
117
+
118
  -0.2.6
119
  - fix sonar grid alignment
120
  - improve score summary layout and styling
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.6"
2
  __all__ = ["models", "generator", "logic", "ui"]
 
1
+ __version__ = "0.2.8"
2
  __all__ = ["models", "generator", "logic", "ui"]
battlewords/assets/audio/congratulations.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da52c73b00948b8c7fe0197f62a57af5ee439b5b1acef31c7add3868d23af437
3
+ size 74446
battlewords/assets/audio/correct_guess.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:397af68b9d343f146c1aa897f9bd64fdb646739edfabfb6ead6f2747fc089d2e
3
+ size 50246
battlewords/assets/audio/hit.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:baaf44f8d29b5543d6c3418ae8ea5d8144046362055b95678b552965f3850a6b
3
+ size 25833
battlewords/assets/audio/incorrect_guess.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1cc06c8c57a1f5acd81661723bcfbf945a253110279b9db7eaca02f89c61667d
3
+ size 49663
battlewords/assets/audio/miss.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e1d774c8241e9295a813f29127e2cb2c148b2ede6845b8ce1702ed970158fc04
3
+ size 25834
battlewords/audio.py CHANGED
@@ -10,9 +10,15 @@ def get_audio_tracks() -> list[tuple[str, str]]:
10
  audio_dir = _get_audio_dir()
11
  if not os.path.isdir(audio_dir):
12
  return []
13
- files = [f for f in os.listdir(audio_dir) if f.lower().endswith(".mp3")]
14
- files.sort()
15
- return [(os.path.splitext(f)[0].replace("_", " ").title(), os.path.join(audio_dir, f)) for f in files]
 
 
 
 
 
 
16
 
17
  @st.cache_data(show_spinner=False)
18
  def _load_audio_data_url(path: str) -> str:
@@ -20,6 +26,7 @@ def _load_audio_data_url(path: str) -> str:
20
  import base64, mimetypes
21
  mime, _ = mimetypes.guess_type(path)
22
  if not mime:
 
23
  mime = "audio/mpeg"
24
  with open(path, "rb") as fp:
25
  encoded = base64.b64encode(fp.read()).decode("ascii")
@@ -130,4 +137,82 @@ def _inject_audio_control_sync():
130
  </script>
131
  ''',
132
  height=0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  )
 
10
  audio_dir = _get_audio_dir()
11
  if not os.path.isdir(audio_dir):
12
  return []
13
+ # Only include .mp3 files, ignore .wav files
14
+ tracks = []
15
+ for fname in os.listdir(audio_dir):
16
+ if fname.lower().endswith('.mp3'):
17
+ path = os.path.join(audio_dir, fname)
18
+ # Use the filename without extension as the display name
19
+ name = os.path.splitext(fname)[0]
20
+ tracks.append((name, path))
21
+ return tracks
22
 
23
  @st.cache_data(show_spinner=False)
24
  def _load_audio_data_url(path: str) -> str:
 
26
  import base64, mimetypes
27
  mime, _ = mimetypes.guess_type(path)
28
  if not mime:
29
+ # Default to mp3 to avoid blocked playback if unknown
30
  mime = "audio/mpeg"
31
  with open(path, "rb") as fp:
32
  encoded = base64.b64encode(fp.read()).decode("ascii")
 
137
  </script>
138
  ''',
139
  height=0,
140
+ )
141
+
142
+ # Sound effects functionality
143
+ def get_sound_effect_files() -> dict[str, str]:
144
+ """
145
+ Return dictionary of sound effect name -> absolute path.
146
+ Prefers .mp3 files; falls back to .wav if no .mp3 is found.
147
+ """
148
+ audio_dir = _get_audio_dir()
149
+ if not os.path.isdir(audio_dir):
150
+ return {}
151
+
152
+ effect_names = [
153
+ "correct_guess",
154
+ "incorrect_guess",
155
+ "hit",
156
+ "miss",
157
+ "congratulations",
158
+ ]
159
+
160
+ def _find_effect_file(base: str) -> Optional[str]:
161
+ # Prefer mp3, then wav for backward compatibility
162
+ for ext in (".mp3", ".wav"):
163
+ path = os.path.join(audio_dir, f"{base}{ext}")
164
+ if os.path.exists(path):
165
+ return path
166
+ return None
167
+
168
+ result: dict[str, str] = {}
169
+ for name in effect_names:
170
+ path = _find_effect_file(name)
171
+ if path:
172
+ result[name] = path
173
+
174
+ return result
175
+
176
+ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
177
+ """
178
+ Play a sound effect by name.
179
+
180
+ Args:
181
+ effect_name: One of 'correct_guess', 'incorrect_guess', 'hit', 'miss', 'congratulations'
182
+ volume: Volume level (0.0 to 1.0)
183
+ """
184
+ from streamlit.components.v1 import html as _html
185
+
186
+ sound_files = get_sound_effect_files()
187
+
188
+ if effect_name not in sound_files:
189
+ return # Sound file doesn't exist, silently skip
190
+
191
+ sound_path = sound_files[effect_name]
192
+ sound_data_url = _load_audio_data_url(sound_path)
193
+
194
+ # Clamp volume
195
+ vol = max(0.0, min(1.0, float(volume)))
196
+
197
+ # Play sound effect using a unique audio element
198
+ _html(
199
+ f"""
200
+ <script>
201
+ (function(){{
202
+ const doc = window.parent?.document || document;
203
+ const audio = doc.createElement('audio');
204
+ audio.src = "{sound_data_url}";
205
+ audio.volume = {vol:.3f};
206
+ audio.style.display = 'none';
207
+ doc.body.appendChild(audio);
208
+
209
+ // Play and remove after playback
210
+ audio.play().catch(e => console.error('Sound effect play error:', e));
211
+ audio.addEventListener('ended', () => {{
212
+ doc.body.removeChild(audio);
213
+ }});
214
+ }})();
215
+ </script>
216
+ """,
217
+ height=0,
218
  )
battlewords/generate_sounds.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/sounds.py CHANGED
@@ -2,61 +2,157 @@
2
 
3
  import os
4
  import tempfile
5
- import torch
6
- from diffusers import StableAudioPipeline
7
- import scipy.io.wavfile as wav
8
  import base64
 
 
 
 
9
 
10
- # Predefined prompts for sound effects
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  EFFECT_PROMPTS = {
12
- "correct guess": "A short, sharp ding sound for a correct guess",
13
- "incorrect guess": "A low buzz sound for an incorrect guess",
14
- "miss": "A soft thud sound for a miss",
15
- "hit": "A bright chime sound for a hit",
16
- "congratulations": "A triumphant fanfare sound for congratulations"
17
  }
18
 
19
  _sound_cache = {}
20
 
21
- def generate_sound_effect(effect: str) -> str:
 
 
 
 
 
 
 
22
  """
23
- Generate a sound effect using Stable Audio Open based on the effect string.
24
- Returns the path to the generated WAV file.
 
 
 
 
 
25
  """
26
  if effect not in EFFECT_PROMPTS:
27
  raise ValueError(f"Unknown effect: {effect}. Available effects: {list(EFFECT_PROMPTS.keys())}")
28
 
29
- # Check cache first
30
- if effect in _sound_cache:
31
- return _sound_cache[effect]
32
-
33
- # Load the model (cached globally)
34
- if not hasattr(generate_sound_effect, 'pipe'):
35
- generate_sound_effect.pipe = StableAudioPipeline.from_pretrained(
36
- "stabilityai/stable-audio-open-1.0",
37
- torch_dtype=torch.float16
38
- )
39
- if torch.cuda.is_available():
40
- generate_sound_effect.pipe = generate_sound_effect.pipe.to("cuda")
41
-
42
- prompt = EFFECT_PROMPTS[effect]
43
-
44
- # Generate audio
45
- audio = generate_sound_effect.pipe(
46
- prompt,
47
- duration=2, # Short duration for sound effects
48
- num_inference_steps=50
49
- ).audio
50
-
51
- # Save to temporary file
52
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpfile:
53
- wav.write(tmpfile.name, 44100, audio[0])
54
- path = tmpfile.name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  # Cache the path
57
  _sound_cache[effect] = path
58
  return path
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def get_sound_effect_path(effect: str) -> str:
61
  """
62
  Get the path to a sound effect, generating it if necessary.
@@ -71,4 +167,29 @@ def get_sound_effect_data_url(effect: str) -> str:
71
  with open(path, "rb") as f:
72
  data = f.read()
73
  encoded = base64.b64encode(data).decode()
74
- return f"data:audio/wav;base64,{encoded}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/ 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 assets directory
83
+ assets_dir = os.path.join(os.path.dirname(__file__), "assets", "audio")
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.
 
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/ 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)
battlewords/ui.py CHANGED
@@ -24,6 +24,7 @@ from .audio import (
24
  _load_audio_data_url,
25
  _mount_background_audio,
26
  _inject_audio_control_sync,
 
27
  )
28
 
29
  st.set_page_config(initial_sidebar_state="collapsed")
@@ -267,6 +268,12 @@ def _init_session() -> None:
267
  # Ensure game_mode is set
268
  if "game_mode" not in st.session_state:
269
  st.session_state.game_mode = "classic"
 
 
 
 
 
 
270
 
271
 
272
  def _new_game() -> None:
@@ -274,6 +281,7 @@ def _new_game() -> None:
274
  mode = st.session_state.get("game_mode")
275
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
276
  spacer = st.session_state.get("spacer", 1)
 
277
  # --- Preserve music settings ---
278
  music_enabled = st.session_state.get("music_enabled", False)
279
  music_track_path = st.session_state.get("music_track_path")
@@ -285,6 +293,7 @@ def _new_game() -> None:
285
  st.session_state.game_mode = mode
286
  st.session_state.show_grid_ticks = show_grid_ticks
287
  st.session_state.spacer = spacer
 
288
  # --- Restore music settings ---
289
  st.session_state.music_enabled = music_enabled
290
  if music_track_path:
@@ -294,6 +303,7 @@ def _new_game() -> None:
294
  st.session_state.radar_gif_signature = None
295
  st.session_state.start_time = datetime.now() # Reset timer on new game
296
  st.session_state.end_time = None
 
297
  _init_session()
298
 
299
 
@@ -392,6 +402,11 @@ def _render_sidebar():
392
  key="spacer"
393
  )
394
 
 
 
 
 
 
395
  # Audio settings
396
  st.header("Audio")
397
  tracks = get_audio_tracks()
@@ -785,6 +800,14 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
785
  reveal_cell(state, letter_map, clicked)
786
  st.session_state.letter_map = build_letter_map(st.session_state.puzzle)
787
  _sync_back(state)
 
 
 
 
 
 
 
 
788
  st.rerun()
789
 
790
  def _sort_wordlist(filename):
@@ -871,19 +894,69 @@ def _render_correct_try_again(state: GameState):
871
 
872
 
873
  def _render_guess_form(state: GameState):
874
- with st.form("guess_form",width=300,clear_on_submit=True):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  col1, col2 = st.columns([2, 1], vertical_alignment="bottom")
876
  with col1:
877
- guess_text = st.text_input("Your Guess", value="", max_chars=10, width=200, key="guess_input")
 
 
 
 
 
 
 
878
  with col2:
879
- submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100,key="guess_submit")
 
 
 
 
 
 
 
 
880
  if submitted:
881
  correct, _ = guess_word(state, guess_text)
882
- _sync_back(state)
883
  # Invalidate radar GIF cache if guess changed the set of guessed words
884
  if correct:
885
  st.session_state.radar_gif_path = None
886
  st.session_state.radar_gif_signature = None
 
 
 
 
 
 
887
  st.rerun()
888
 
889
 
@@ -993,6 +1066,9 @@ def _render_score_panel(state: GameState):
993
  # -------------------- Game Over Dialog --------------------
994
 
995
  def _game_over_content(state: GameState) -> None:
 
 
 
996
  # Set end_time if not already set
997
  if state.end_time is None:
998
  st.session_state.end_time = datetime.now()
 
24
  _load_audio_data_url,
25
  _mount_background_audio,
26
  _inject_audio_control_sync,
27
+ play_sound_effect,
28
  )
29
 
30
  st.set_page_config(initial_sidebar_state="collapsed")
 
268
  # Ensure game_mode is set
269
  if "game_mode" not in st.session_state:
270
  st.session_state.game_mode = "classic"
271
+ # Initialize incorrect guesses tracking
272
+ if "incorrect_guesses" not in st.session_state:
273
+ st.session_state.incorrect_guesses = []
274
+ # Initialize show_incorrect_guesses to True by default
275
+ if "show_incorrect_guesses" not in st.session_state:
276
+ st.session_state.show_incorrect_guesses = True
277
 
278
 
279
  def _new_game() -> None:
 
281
  mode = st.session_state.get("game_mode")
282
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
283
  spacer = st.session_state.get("spacer", 1)
284
+ show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
285
  # --- Preserve music settings ---
286
  music_enabled = st.session_state.get("music_enabled", False)
287
  music_track_path = st.session_state.get("music_track_path")
 
293
  st.session_state.game_mode = mode
294
  st.session_state.show_grid_ticks = show_grid_ticks
295
  st.session_state.spacer = spacer
296
+ st.session_state.show_incorrect_guesses = show_incorrect_guesses
297
  # --- Restore music settings ---
298
  st.session_state.music_enabled = music_enabled
299
  if music_track_path:
 
303
  st.session_state.radar_gif_signature = None
304
  st.session_state.start_time = datetime.now() # Reset timer on new game
305
  st.session_state.end_time = None
306
+ st.session_state.incorrect_guesses = [] # Clear incorrect guesses for new game
307
  _init_session()
308
 
309
 
 
402
  key="spacer"
403
  )
404
 
405
+ # Add Show Incorrect Guesses option - now enabled by default
406
+ if "show_incorrect_guesses" not in st.session_state:
407
+ st.session_state.show_incorrect_guesses = True
408
+ st.checkbox("Show incorrect guesses", value=st.session_state.show_incorrect_guesses, key="show_incorrect_guesses")
409
+
410
  # Audio settings
411
  st.header("Audio")
412
  tracks = get_audio_tracks()
 
800
  reveal_cell(state, letter_map, clicked)
801
  st.session_state.letter_map = build_letter_map(st.session_state.puzzle)
802
  _sync_back(state)
803
+
804
+ # Play sound effect based on hit or miss
805
+ action = (state.last_action or "").strip()
806
+ if action.startswith("Revealed '"):
807
+ play_sound_effect("hit", volume=0.5)
808
+ elif action.startswith("Revealed empty"):
809
+ play_sound_effect("miss", volume=0.5)
810
+
811
  st.rerun()
812
 
813
  def _sort_wordlist(filename):
 
894
 
895
 
896
  def _render_guess_form(state: GameState):
897
+ # Initialize incorrect guesses list in session state (safety check)
898
+ if "incorrect_guesses" not in st.session_state:
899
+ st.session_state.incorrect_guesses = []
900
+
901
+ # Prepare tooltip text for native browser tooltip
902
+ recent_incorrect = st.session_state.incorrect_guesses[-10:]
903
+ if recent_incorrect:
904
+ tooltip_text = "Recent incorrect guesses:\n" + "\n".join(recent_incorrect)
905
+ else:
906
+ tooltip_text = "No incorrect guesses yet"
907
+
908
+ # Add CSS for the incorrect guesses display
909
+ st.markdown(
910
+ """
911
+ <style>
912
+ .bw-incorrect-guesses {
913
+ font-size: 0.85rem;
914
+ color: #ff9999;
915
+ margin-top: 4px;
916
+ padding: 4px 8px;
917
+ background: rgba(255, 255, 255, 0.05);
918
+ border-radius: 4px;
919
+ font-style: italic;
920
+ }
921
+ </style>
922
+ """,
923
+ unsafe_allow_html=True,
924
+ )
925
+
926
+ with st.form("guess_form", width=300, clear_on_submit=True):
927
  col1, col2 = st.columns([2, 1], vertical_alignment="bottom")
928
  with col1:
929
+ guess_text = st.text_input(
930
+ "Your Guess",
931
+ value="",
932
+ max_chars=10,
933
+ width=200,
934
+ key="guess_input",
935
+ help=tooltip_text # Use Streamlit's built-in help parameter for tooltip
936
+ )
937
  with col2:
938
+ submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100, key="guess_submit")
939
+
940
+ # Show compact list below input if setting is enabled
941
+ if st.session_state.get("show_incorrect_guesses", False) and recent_incorrect:
942
+ st.markdown(
943
+ f'<div class="bw-incorrect-guesses">Recent: {", ".join(recent_incorrect)}</div>',
944
+ unsafe_allow_html=True,
945
+ )
946
+
947
  if submitted:
948
  correct, _ = guess_word(state, guess_text)
949
+ _sync_back(state)
950
  # Invalidate radar GIF cache if guess changed the set of guessed words
951
  if correct:
952
  st.session_state.radar_gif_path = None
953
  st.session_state.radar_gif_signature = None
954
+ play_sound_effect("correct_guess", volume=0.6)
955
+ else:
956
+ # Update incorrect guesses list - keep only last 10
957
+ st.session_state.incorrect_guesses.append(guess_text)
958
+ st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
959
+ play_sound_effect("incorrect_guess", volume=0.5)
960
  st.rerun()
961
 
962
 
 
1066
  # -------------------- Game Over Dialog --------------------
1067
 
1068
  def _game_over_content(state: GameState) -> None:
1069
+ # Play congratulations sound when game ends
1070
+ play_sound_effect("congratulations", volume=0.7)
1071
+
1072
  # Set end_time if not already set
1073
  if state.end_time is None:
1074
  st.session_state.end_time = datetime.now()
claude.md ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BattleWords - Project Context
2
+
3
+ ## Project Overview
4
+ BattleWords is a vocabulary learning game inspired by Battleship mechanics, built with Streamlit and Python 3.12. Players reveal cells on a 12x12 grid to discover hidden words and earn points for strategic guessing.
5
+
6
+ **Current Version:** 0.2.6
7
+ **Repository:** https://github.com/Oncorporation/BattleWords.git
8
+ **Live Demo:** https://huggingface.co/spaces/Surn/BattleWords
9
+
10
+ ## Core Gameplay
11
+ - 12x12 grid with 6 hidden words (2Γ—4-letter, 2Γ—5-letter, 2Γ—6-letter)
12
+ - Words placed horizontally or vertically, no overlaps
13
+ - Players click cells to reveal letters or empty spaces
14
+ - After revealing a letter, players can guess words
15
+ - Scoring: word length + bonus for unrevealed letters
16
+ - Game ends when all words are guessed or all word letters revealed
17
+
18
+ ### Scoring Tiers
19
+ - **Fantastic:** 42+ points
20
+ - **Great:** 38-41 points
21
+ - **Good:** 34-37 points
22
+ - **Keep practicing:** < 34 points
23
+
24
+ ## Technical Architecture
25
+
26
+ ### Technology Stack
27
+ - **Framework:** Streamlit 1.50.0
28
+ - **Language:** Python 3.12.8
29
+ - **Visualization:** Matplotlib, NumPy
30
+ - **Data Processing:** Pandas, Altair
31
+ - **Testing:** Pytest
32
+ - **Code Quality:** Flake8, MyPy
33
+ - **Package Manager:** UV (modern Python package manager)
34
+
35
+ ### Project Structure
36
+ ```
37
+ battlewords/
38
+ β”œβ”€β”€ app.py # Streamlit entry point
39
+ β”œβ”€β”€ battlewords/ # Main package
40
+ β”‚ β”œβ”€β”€ __init__.py # Version: 0.2.6
41
+ β”‚ β”œβ”€β”€ models.py # Data models (Coord, Word, Puzzle, GameState)
42
+ β”‚ β”œβ”€β”€ generator.py # Puzzle generation with deterministic seeding
43
+ β”‚ β”œβ”€β”€ logic.py # Game mechanics (reveal, guess, scoring)
44
+ β”‚ β”œβ”€β”€ ui.py # Streamlit UI (~1170 lines)
45
+ β”‚ β”œβ”€β”€ word_loader.py # Word list management
46
+ β”‚ β”œβ”€β”€ audio.py # Background music system
47
+ β”‚ β”œβ”€β”€ version_info.py # Version display
48
+ β”‚ └── words/ # Word list files
49
+ β”‚ β”œβ”€β”€ classic.txt # Default word list
50
+ β”‚ └── wordlist.txt # Additional words
51
+ β”œβ”€β”€ tests/ # Unit tests
52
+ β”œβ”€β”€ specs/ # Documentation
53
+ β”œβ”€β”€ .env # Environment variables
54
+ β”œβ”€β”€ pyproject.toml # Project metadata
55
+ β”œβ”€β”€ requirements.txt # Dependencies
56
+ β”œβ”€β”€ uv.lock # UV lock file
57
+ └── Dockerfile # Container deployment
58
+ ```
59
+
60
+ ## Key Features
61
+
62
+ ### Game Modes
63
+ 1. **Classic Mode:** Allows consecutive guessing after correct answers
64
+ 2. **Too Easy Mode:** Single guess per reveal
65
+
66
+ ### Puzzle Generation
67
+ - Deterministic seeding support for reproducible puzzles
68
+ - Configurable word spacing (spacer: 0-2)
69
+ - 0: Words may touch
70
+ - 1: At least 1 blank cell between words (default)
71
+ - 2: At least 2 blank cells between words
72
+ - Validation ensures no overlaps, proper bounds, correct word distribution
73
+
74
+ ### UI Components
75
+ - **Radar Visualization:** Animated matplotlib GIF showing word boundaries
76
+ - Displays pulsing rings at last letter of each word
77
+ - Hides rings for guessed words
78
+ - Three-layer composition: gradient background, scope image, animated rings
79
+ - Cached per-puzzle with signature matching
80
+ - **Game Grid:** Interactive 12x12 button grid
81
+ - **Score Panel:** Real-time scoring with client-side timer
82
+ - **Settings Sidebar:** Word list picker, game mode, spacing, audio controls
83
+ - **Theme System:** Ocean gradient background with animations
84
+ - **Audio System:** Background music with volume control
85
+
86
+ ### Recent Fixes (cc-01 branch)
87
+ - **Radar Alignment Issue:** Fixed inconsistent sizing of animated rings layer
88
+ - Added `fig.subplots_adjust(left=0, right=0.9, top=0.9, bottom=0)`
89
+ - Set `fig.patch.set_alpha(0.0)` for transparent background
90
+ - Maintains 2% margin for tick visibility while ensuring consistent layer alignment
91
+
92
+ ## Data Models
93
+
94
+ ### Core Classes
95
+ ```python
96
+ @dataclass
97
+ class Coord:
98
+ x: int # row, 0-based
99
+ y: int # col, 0-based
100
+
101
+ @dataclass
102
+ class Word:
103
+ text: str
104
+ start: Coord
105
+ direction: Direction # "H" or "V"
106
+ cells: List[Coord]
107
+
108
+ @dataclass
109
+ class Puzzle:
110
+ words: List[Word]
111
+ radar: List[Coord]
112
+ may_overlap: bool
113
+ spacer: int
114
+ uid: str # Unique identifier for caching
115
+
116
+ @dataclass
117
+ class GameState:
118
+ grid_size: int
119
+ puzzle: Puzzle
120
+ revealed: Set[Coord]
121
+ guessed: Set[str]
122
+ score: int
123
+ last_action: str
124
+ can_guess: bool
125
+ game_mode: str
126
+ points_by_word: Dict[str, int]
127
+ start_time: Optional[datetime]
128
+ end_time: Optional[datetime]
129
+ ```
130
+
131
+ ## Development Workflow
132
+
133
+ ### Running Locally
134
+ ```bash
135
+ # Install dependencies
136
+ uv pip install -r requirements.txt --link-mode=copy
137
+
138
+ # Run app
139
+ uv run streamlit run app.py
140
+ # or
141
+ streamlit run app.py
142
+ ```
143
+
144
+ ### Docker Deployment
145
+ ```bash
146
+ docker build -t battlewords .
147
+ docker run -p 8501:8501 battlewords
148
+ ```
149
+
150
+ ### Testing
151
+ ```bash
152
+ pytest tests/
153
+ ```
154
+
155
+ ## Current Development Branch
156
+ **Branch:** cc-01
157
+ **Purpose:** Bug fixes and improvements, specifically radar graphic alignment
158
+
159
+ ### Git Configuration
160
+ - **Remote ONCORP:** https://github.com/Oncorporation/BattleWords.git (main remote)
161
+ - **Remote Hugging:** https://huggingface.co/spaces/Surn/BattleWords (deployment)
162
+
163
+ ## Known Issues
164
+ - Word list loading bug: App may not select proper word lists in some environments
165
+ - Investigation needed in `word_loader.get_wordlist_files()` and `load_word_list()`
166
+ - Sidebar selection persistence needs verification
167
+
168
+ ## Future Roadmap
169
+
170
+ ### Beta (0.5.0)
171
+ - Word overlaps on shared letters (crossword-style)
172
+ - Enhanced responsive layout
173
+ - Keyboard navigation and guessing
174
+ - Deterministic seed UI
175
+
176
+ ### Full (1.0.0)
177
+ - Daily puzzle mode
178
+ - Practice mode
179
+ - Leaderboards
180
+ - Persistent storage
181
+ - Enhanced UX features
182
+
183
+ ## Deployment Targets
184
+ - **Hugging Face Spaces:** Primary deployment platform
185
+ - **Docker:** Containerized deployment for any platform
186
+ - **Local:** Development and testing
187
+
188
+ ## Notes for Claude
189
+ - Project uses modern Python features (3.12+)
190
+ - Heavy use of Streamlit session state for game state management
191
+ - Matplotlib figures are converted to PIL images and animated GIFs
192
+ - Client-side JavaScript for timer updates without page refresh
193
+ - CSS heavily customized for game aesthetics
194
+ - All file paths should be absolute when working in WSL environment
195
+ - Current working directory: `/mnt/d/Projects/Battlewords`
requirements.txt CHANGED
@@ -6,4 +6,5 @@ numpy
6
  Pillow
7
  pytest
8
  flake8
9
- mypy
 
 
6
  Pillow
7
  pytest
8
  flake8
9
+ mypy
10
+ requests