Surn's picture
0.2.18
7606f52
import os
from typing import Optional
import streamlit as st
def _get_music_dir() -> str:
return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
def _get_effects_dir() -> str:
return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
def get_audio_tracks() -> list[tuple[str, str]]:
"""Return list of (label, absolute_path) for .mp3 files in assets/audio/music."""
audio_dir = _get_music_dir()
if not os.path.isdir(audio_dir):
return []
tracks = []
for fname in os.listdir(audio_dir):
if fname.lower().endswith('.mp3'):
path = os.path.join(audio_dir, fname)
# Use the filename without extension as the display name
name = os.path.splitext(fname)[0]
tracks.append((name, path))
return tracks
@st.cache_data(show_spinner=False)
def _load_audio_data_url(path: str) -> str:
"""Return a data: URL for the given audio file so the browser can play it."""
import base64, mimetypes
mime, _ = mimetypes.guess_type(path)
if not mime:
# Default to mp3 to avoid blocked playback if unknown
mime = "audio/mpeg"
with open(path, "rb") as fp:
encoded = base64.b64encode(fp.read()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume: float, loop: bool = True) -> None:
"""Create/update a single hidden <audio> element in the top page and play/pause it.
Args:
enabled: Whether the background audio should be active.
src_data_url: data: URL for the audio source.
volume: 0.0–1.0 volume level.
loop: Whether the audio should loop (default True).
"""
from streamlit.components.v1 import html as _html
if not enabled or not src_data_url:
_html(
"""
<script>
(function(){
const doc = window.parent?.document || document;
const el = doc.getElementById('bw-bg-audio');
if (el) { try { el.pause(); } catch(e){} }
})();
</script>
""",
height=0,
)
return
# Clamp volume
vol = max(0.0, min(1.0, float(volume)))
should_loop = "true" if loop else "false"
# Inject or update a single persistent audio element and make sure it starts after interaction if autoplay is blocked
_html(
f"""
<script>
(function(){{
const doc = window.parent?.document || document;
let audio = doc.getElementById('bw-bg-audio');
if (!audio) {{
audio = doc.createElement('audio');
audio.id = 'bw-bg-audio';
audio.style.display = 'none';
doc.body.appendChild(audio);
}}
// Ensure loop is explicitly set every time, even if element already exists
const shouldLoop = {should_loop};
audio.loop = shouldLoop;
if (shouldLoop) {{
audio.setAttribute('loop', '');
}} else {{
audio.removeAttribute('loop');
}}
audio.autoplay = true;
audio.setAttribute('autoplay', '');
const newSrc = "{src_data_url}";
if (audio.src !== newSrc) {{
audio.src = newSrc;
}}
audio.muted = false;
audio.volume = {vol:.3f};
const tryPlay = () => {{
const p = audio.play();
if (p && p.catch) {{ p.catch(() => {{ /* ignore autoplay block until user gesture */ }}); }}
}};
tryPlay();
const unlock = () => {{
tryPlay();
}};
// Add once-only listeners to resume playback after first user interaction
doc.addEventListener('pointerdown', unlock, {{ once: true }});
doc.addEventListener('keydown', unlock, {{ once: true }});
doc.addEventListener('touchstart', unlock, {{ once: true }});
}})();
</script>
""",
height=0,
)
def _inject_audio_control_sync():
"""Inject JS to sync volume and enable/disable state immediately."""
from streamlit.components.v1 import html as _html
_html(
'''
<script>
(function(){
const doc = window.parent?.document || document;
const audio = doc.getElementById('bw-bg-audio');
if (!audio) return;
// Get values from Streamlit DOM
const volInput = doc.querySelector('input[type="range"][aria-label="Volume"]');
const enableInput = doc.querySelector('input[type="checkbox"][aria-label="Enable music"]');
if (volInput) {
volInput.addEventListener('input', function(){
audio.volume = parseFloat(this.value)/100;
});
// Set initial volume
audio.volume = parseFloat(volInput.value)/100;
}
if (enableInput) {
enableInput.addEventListener('change', function(){
if (this.checked) {
audio.muted = false;
audio.play().catch(()=>{});
} else {
audio.muted = true;
audio.pause();
}
});
// Set initial mute state
if (enableInput.checked) {
audio.muted = false;
audio.play().catch(()=>{});
} else {
audio.muted = true;
audio.pause();
}
}
})();
</script>
''',
height=0,
)
# Sound effects functionality
def get_sound_effect_files() -> dict[str, str]:
"""
Return dictionary of sound effect name -> absolute path.
Prefers .mp3 files; falls back to .wav if no .mp3 is found.
"""
audio_dir = _get_effects_dir()
if not os.path.isdir(audio_dir):
return {}
effect_names = [
"correct_guess",
"incorrect_guess",
"hit",
"miss",
"congratulations",
]
def _find_effect_file(base: str) -> Optional[str]:
# Prefer mp3, then wav for backward compatibility
for ext in (".mp3", ".wav"):
path = os.path.join(audio_dir, f"{base}{ext}")
if os.path.exists(path):
return path
return None
result: dict[str, str] = {}
for name in effect_names:
path = _find_effect_file(name)
if path:
result[name] = path
return result
def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
"""
Play a sound effect by name.
Args:
effect_name: One of 'correct_guess', 'incorrect_guess', 'hit', 'miss', 'congratulations'
volume: Volume level (0.0 to 1.0)
"""
from streamlit.components.v1 import html as _html
# Respect Enable Sound Effects setting from sidebar
try:
if not st.session_state.get("enable_sound_effects", True):
return
except Exception:
pass
sound_files = get_sound_effect_files()
if effect_name not in sound_files:
return # Sound file doesn't exist, silently skip
sound_path = sound_files[effect_name]
sound_data_url = _load_audio_data_url(sound_path)
# Clamp volume
vol = max(0.0, min(1.0, float(volume)))
# Play sound effect using a unique audio element
_html(
f"""
<script>
(function(){{
const doc = window.parent?.document || document;
const audio = doc.createElement('audio');
audio.src = "{sound_data_url}";
audio.volume = {vol:.3f};
audio.style.display = 'none';
doc.body.appendChild(audio);
// Play and remove after playback
audio.play().catch(e => console.error('Sound effect play error:', e));
audio.addEventListener('ended', () => {{
doc.body.removeChild(audio);
}});
}})();
</script>
""",
height=0,
)