|
| 1 | +""" |
| 2 | +WebAudio: minimal beep — Pyodide sanity check. |
| 3 | +
|
| 4 | +**Pyodide only.** Calls the browser's WebAudio API via Python's `js` interop. |
| 5 | +Click **Beep** to hear a short sine tone whose frequency and duration you |
| 6 | +can slide. |
| 7 | +
|
| 8 | +Why this exists: confirms that audio works in the Pyodide playground |
| 9 | +without needing an SDL2-audio-enabled build or a Python audio binding. |
| 10 | +Same pattern as the `webgl_minimal_mandelbrot.py` demo, just talking to |
| 11 | +`AudioContext` instead of `WebGL2RenderingContext`. |
| 12 | +
|
| 13 | +## Gotcha |
| 14 | +
|
| 15 | +Browsers require a *user gesture* before an `AudioContext` is allowed to |
| 16 | +produce sound. We create the context lazily inside the Beep button |
| 17 | +handler — the button click is the gesture, so playback starts on the |
| 18 | +very first press. |
| 19 | +""" |
| 20 | +from imgui_bundle import imgui, immapp |
| 21 | + |
| 22 | + |
| 23 | +_audio_ctx = None # js AudioContext (Pyodide); None on desktop or until first click |
| 24 | +_frequency = 440.0 # Hz |
| 25 | +_duration_ms = 200 # ms |
| 26 | +_status = "" # last-action message shown under the controls |
| 27 | + |
| 28 | + |
| 29 | +def _ensure_audio_context(): |
| 30 | + """Lazy-init the browser's AudioContext on first call. No-op on desktop.""" |
| 31 | + global _audio_ctx, _status |
| 32 | + if _audio_ctx is not None: |
| 33 | + return _audio_ctx |
| 34 | + try: |
| 35 | + from js import AudioContext # type: ignore[import-not-found,import-untyped] |
| 36 | + _audio_ctx = AudioContext.new() |
| 37 | + _status = "AudioContext ready" |
| 38 | + except Exception as e: |
| 39 | + _status = f"AudioContext unavailable: {e}" |
| 40 | + return _audio_ctx |
| 41 | + |
| 42 | + |
| 43 | +def _beep(): |
| 44 | + global _status |
| 45 | + ctx = _ensure_audio_context() |
| 46 | + if ctx is None: |
| 47 | + return |
| 48 | + try: |
| 49 | + osc = ctx.createOscillator() |
| 50 | + gain = ctx.createGain() |
| 51 | + osc.type = "sine" |
| 52 | + osc.frequency.value = _frequency |
| 53 | + now = ctx.currentTime |
| 54 | + end = now + _duration_ms / 1000.0 |
| 55 | + # Short attack/release ramp avoids speaker-click on start/stop |
| 56 | + gain.gain.setValueAtTime(0.0, now) |
| 57 | + gain.gain.linearRampToValueAtTime(0.3, now + 0.01) |
| 58 | + gain.gain.linearRampToValueAtTime(0.0, end) |
| 59 | + osc.connect(gain) |
| 60 | + gain.connect(ctx.destination) |
| 61 | + osc.start(now) |
| 62 | + osc.stop(end + 0.05) |
| 63 | + _status = f"Beep @ {_frequency:.0f} Hz for {_duration_ms} ms" |
| 64 | + except Exception as e: |
| 65 | + _status = f"Beep failed: {e}" |
| 66 | + |
| 67 | + |
| 68 | +def gui(): |
| 69 | + global _frequency, _duration_ms |
| 70 | + imgui.text("WebAudio sanity check (Pyodide only)") |
| 71 | + imgui.separator() |
| 72 | + _, _frequency = imgui.slider_float("Frequency (Hz)", _frequency, 80.0, 2000.0) |
| 73 | + _, _duration_ms = imgui.slider_int("Duration (ms)", _duration_ms, 50, 1000) |
| 74 | + if imgui.button("Beep"): |
| 75 | + _beep() |
| 76 | + if _status: |
| 77 | + imgui.text_disabled(_status) |
| 78 | + |
| 79 | + |
| 80 | +immapp.run(gui, window_title="WebAudio beep", window_size=(500, 220)) |
0 commit comments