Browse Source

Add microphone visualizer tool using SDL2 for audio input visualization

- Implemented a new Python script `mic_visualizer.py` that captures audio from a microphone and visualizes it in real-time.
- Utilized SDL2 for audio capture and rendering, allowing users to see waveform and spectrum representations of the audio input.
- Added command-line arguments for listing devices, selecting a capture device, and configuring window size and audio settings.
- Included functionality for displaying audio levels and peaks, enhancing user experience with visual feedback.
master
Matteo Benedetto 1 month ago
parent
commit
7868d83ced
  1. 10
      README.md
  2. BIN
      cover.png
  3. 2
      gameinfo.xml
  4. BIN
      intro.png
  5. BIN
      packaging/muos/MUOS/info/catalogue/External - Ports/box/Mice!.png
  6. BIN
      packaging/muos/MUOS/info/catalogue/External - Ports/box/mice.png
  7. 2
      packaging/muos/MUOS/info/catalogue/External - Ports/text/Mice!.txt
  8. 2
      packaging/muos/MUOS/info/catalogue/External - Ports/text/mice.txt
  9. 315
      tools/mic_visualizer.py

10
README.md

@ -15,6 +15,16 @@ Mice! is a strategic game where players must kill rats with bombs before they re
- **Scoring**: Points system to track player progress.
- **Performance**: Optimized collision detection system supporting 200+ simultaneous units using NumPy vectorization.
## Utilities
### Microphone Visualizer
A small SDL2 microphone visualizer is available in `tools/mic_visualizer.py`.
- List capture devices: `python tools/mic_visualizer.py --list-devices`
- Open the default microphone: `python tools/mic_visualizer.py`
- Open a specific input: `python tools/mic_visualizer.py --device-index 1`
## Engine Architecture
The Mice! game engine is built on a modular architecture designed for flexibility and maintainability. The engine follows a component-based design pattern where different systems handle specific aspects of the game.

BIN
cover.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 683 KiB

2
gameinfo.xml

@ -3,7 +3,7 @@
<game>
<path>./mice.sh</path>
<name>Mice!</name>
<desc>Mice! is a strategic singleplayer game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes (DFS), sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL‑style rendering.</desc>
<desc>Mice! is a strategic single-player game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes (DFS), sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL-style rendering. Created by Matteo Benedetto, a bored engineer.</desc>
<releasedate>20250818T000000</releasedate>
<developer>Matteo Benedetto</developer>
<publisher>Self-published</publisher>

BIN
intro.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

BIN
packaging/muos/MUOS/info/catalogue/External - Ports/box/Mice!.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 683 KiB

BIN
packaging/muos/MUOS/info/catalogue/External - Ports/box/mice.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 683 KiB

2
packaging/muos/MUOS/info/catalogue/External - Ports/text/Mice!.txt

@ -1 +1 @@
Mice! is a strategic single-player game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes, sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL-style rendering.
Mice! is a strategic single-player game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes, sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL-style rendering. Created by Matteo Benedetto, a bored engineer.

2
packaging/muos/MUOS/info/catalogue/External - Ports/text/mice.txt

@ -1 +1 @@
Mice! is a strategic single-player game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes, sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL-style rendering.
Mice! is a strategic single-player game where you place bombs and mines to exterminate rats before they reproduce out of control. It features randomly generated mazes, sprite-based graphics, and sound effects. Inspired by the classic Rats! for Windows 95, this version is written in Python and uses a lightweight custom engine with SDL-style rendering. Created by Matteo Benedetto, a bored engineer.

315
tools/mic_visualizer.py

@ -0,0 +1,315 @@
#!/usr/bin/env python3
import argparse
import ctypes
import math
import numpy as np
import sdl2
import sdl2.ext
def _decode_sdl_string(value):
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return ctypes.cast(value, ctypes.c_char_p).value.decode("utf-8", errors="replace")
def list_capture_devices():
if sdl2.SDL_Init(sdl2.SDL_INIT_AUDIO) != 0:
raise RuntimeError(_decode_sdl_string(sdl2.SDL_GetError()) or "Failed to initialize SDL audio")
try:
count = sdl2.SDL_GetNumAudioDevices(1)
return [
_decode_sdl_string(sdl2.SDL_GetAudioDeviceName(index, 1)) or f"Capture device {index}"
for index in range(count)
]
finally:
sdl2.SDL_QuitSubSystem(sdl2.SDL_INIT_AUDIO)
class MicVisualizer:
def __init__(self, width, height, device_index=None, history_seconds=1.5, sample_rate=48000, buffer_samples=1024):
if sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO | sdl2.SDL_INIT_AUDIO) != 0:
raise RuntimeError(_decode_sdl_string(sdl2.SDL_GetError()) or "Failed to initialize SDL")
self.width = width
self.height = height
self.running = True
self.level = 0.0
self.peak = 0.0
self.history_seconds = history_seconds
self.title_frame_counter = 0
self.window = sdl2.ext.Window("Mic Visualizer", size=(self.width, self.height))
self.window.show()
self.renderer = sdl2.ext.Renderer(
self.window,
flags=sdl2.SDL_RENDERER_ACCELERATED | sdl2.SDL_RENDERER_PRESENTVSYNC,
)
self.sdl_renderer = self.renderer.sdlrenderer
self.device_name, obtained = self._open_capture_device(
device_index=device_index,
sample_rate=sample_rate,
buffer_samples=buffer_samples,
)
self.sample_rate = int(obtained.freq)
self.channels = int(obtained.channels)
self.sample_width = ctypes.sizeof(ctypes.c_int16)
self.bytes_per_frame = self.sample_width * self.channels
self.waveform = np.zeros(max(512, int(self.sample_rate * self.history_seconds)), dtype=np.float32)
self.spectrum = np.zeros(64, dtype=np.float32)
sdl2.SDL_PauseAudioDevice(self.audio_device, 0)
self._update_title(force=True)
def _open_capture_device(self, device_index, sample_rate, buffer_samples):
requested_name = None
if device_index is not None:
available = list_capture_devices()
if device_index < 0 or device_index >= len(available):
raise ValueError(f"Capture device index {device_index} out of range")
requested_name = available[device_index]
desired = sdl2.SDL_AudioSpec()
desired.freq = int(sample_rate)
desired.format = sdl2.AUDIO_S16SYS
desired.channels = 1
desired.samples = int(buffer_samples)
obtained = sdl2.SDL_AudioSpec()
requested_name_bytes = requested_name.encode("utf-8") if requested_name else None
allowed_changes = sdl2.SDL_AUDIO_ALLOW_FREQUENCY_CHANGE | sdl2.SDL_AUDIO_ALLOW_CHANNELS_CHANGE
self.audio_device = sdl2.SDL_OpenAudioDevice(requested_name_bytes, 1, desired, obtained, allowed_changes)
if not self.audio_device:
error = _decode_sdl_string(sdl2.SDL_GetError()) or "Failed to open capture device"
raise RuntimeError(error)
if int(obtained.format) != int(sdl2.AUDIO_S16SYS):
raise RuntimeError("Microphone visualizer expects 16-bit signed audio input")
return requested_name or "Default capture device", obtained
def _update_waveform(self, samples):
if samples.size == 0:
return
sample_count = min(samples.size, self.waveform.size)
if sample_count >= self.waveform.size:
self.waveform[:] = samples[-self.waveform.size :]
return
self.waveform[:-sample_count] = self.waveform[sample_count:]
self.waveform[-sample_count:] = samples[-sample_count:]
def _update_spectrum(self, samples):
if samples.size < 128:
self.spectrum *= 0.95
return
fft_size = min(2048, samples.size)
window = np.hanning(fft_size).astype(np.float32)
frame = samples[-fft_size:] * window
magnitudes = np.abs(np.fft.rfft(frame))
if magnitudes.size <= 1:
self.spectrum *= 0.95
return
magnitudes = np.log1p(magnitudes[1:])
bin_edges = np.linspace(0, magnitudes.size, self.spectrum.size + 1, dtype=np.int32)
reduced = np.zeros_like(self.spectrum)
for index in range(self.spectrum.size):
start = int(bin_edges[index])
end = int(bin_edges[index + 1])
if end > start:
reduced[index] = float(np.mean(magnitudes[start:end]))
max_value = float(np.max(reduced))
if max_value > 0:
reduced /= max_value
self.spectrum = self.spectrum * 0.65 + reduced * 0.35
def _pull_audio(self):
queued = int(sdl2.SDL_GetQueuedAudioSize(self.audio_device))
if queued < self.bytes_per_frame:
self.level *= 0.96
self.peak *= 0.94
self.spectrum *= 0.97
return
queued -= queued % self.bytes_per_frame
raw_buffer = (ctypes.c_ubyte * queued)()
dequeued = int(sdl2.SDL_DequeueAudio(self.audio_device, raw_buffer, queued))
if dequeued <= 0:
return
audio_bytes = bytes(raw_buffer[:dequeued])
pcm = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32)
if self.channels > 1:
pcm = pcm.reshape(-1, self.channels).mean(axis=1)
samples = pcm / 32768.0
self._update_waveform(samples)
self._update_spectrum(samples)
rms = float(np.sqrt(np.mean(samples * samples))) if samples.size else 0.0
peak = float(np.max(np.abs(samples))) if samples.size else 0.0
self.level = self.level * 0.82 + rms * 0.18
self.peak = max(peak, self.peak * 0.93)
def _set_color(self, rgba):
sdl2.SDL_SetRenderDrawColor(self.sdl_renderer, *rgba)
def _draw_line(self, x1, y1, x2, y2):
sdl2.SDL_RenderDrawLine(self.sdl_renderer, int(x1), int(y1), int(x2), int(y2))
def _fill_rect(self, x, y, width, height):
rect = sdl2.SDL_Rect(int(x), int(y), int(width), int(height))
sdl2.SDL_RenderFillRect(self.sdl_renderer, ctypes.byref(rect))
def _draw_background(self):
self.renderer.clear((8, 10, 18, 255))
self._set_color((22, 28, 40, 255))
for index in range(1, 5):
y = int(self.height * 0.1 + index * self.height * 0.12)
self._draw_line(0, y, self.width, y)
for index in range(1, 8):
x = int(index * self.width / 8)
self._draw_line(x, 0, x, self.height)
center_y = int(self.height * 0.32)
self._set_color((40, 54, 78, 255))
self._draw_line(0, center_y, self.width, center_y)
def _draw_waveform(self):
center_y = int(self.height * 0.32)
amplitude = int(self.height * 0.23)
indices = np.linspace(0, self.waveform.size - 1, self.width, dtype=np.int32)
points = self.waveform[indices]
color_boost = min(1.0, self.level * 3.5)
red = int(70 + 90 * color_boost)
green = int(190 + 45 * color_boost)
blue = int(210 + 20 * color_boost)
self._set_color((red, green, blue, 255))
for x in range(self.width - 1):
y1 = center_y - points[x] * amplitude
y2 = center_y - points[x + 1] * amplitude
self._draw_line(x, y1, x + 1, y2)
def _draw_spectrum(self):
origin_y = int(self.height * 0.62)
band_height = int(self.height * 0.28)
band_width = max(3, self.width // (self.spectrum.size * 2))
gap = band_width
total_width = self.spectrum.size * (band_width + gap) - gap
start_x = max(0, (self.width - total_width) // 2)
for index, value in enumerate(self.spectrum):
height = max(2, int(value * band_height))
x = start_x + index * (band_width + gap)
hue = index / max(1, self.spectrum.size - 1)
red = int(90 + 120 * value)
green = int(120 + 100 * (1.0 - abs(hue - 0.4)))
blue = int(180 + 60 * (1.0 - hue))
self._set_color((red, green, blue, 255))
self._fill_rect(x, origin_y + band_height - height, band_width, height)
def _draw_level_meter(self):
meter_width = 28
meter_height = int(self.height * 0.46)
meter_x = self.width - meter_width - 28
meter_y = 28
self._set_color((26, 32, 48, 255))
self._fill_rect(meter_x, meter_y, meter_width, meter_height)
filled = int(meter_height * min(1.0, self.level * 4.0))
red = int(80 + min(175, filled))
green = int(130 + min(100, filled // 2))
self._set_color((red, green, 90, 255))
self._fill_rect(meter_x + 4, meter_y + meter_height - filled, meter_width - 8, filled)
peak_y = meter_y + meter_height - int(meter_height * min(1.0, self.peak))
self._set_color((255, 245, 180, 255))
self._draw_line(meter_x, peak_y, meter_x + meter_width, peak_y)
def _update_title(self, force=False):
if not force and self.title_frame_counter % 10 != 0:
self.title_frame_counter += 1
return
self.title_frame_counter += 1
level_percent = int(round(min(1.0, self.level * 4.0) * 100))
peak_percent = int(round(min(1.0, self.peak) * 100))
title = f"Mic Visualizer | {self.device_name} | level {level_percent}% | peak {peak_percent}% | Esc quits"
sdl2.SDL_SetWindowTitle(self.window.window, title.encode("utf-8"))
def _handle_events(self):
for event in sdl2.ext.get_events():
if event.type == sdl2.SDL_QUIT:
self.running = False
elif event.type == sdl2.SDL_KEYDOWN:
if event.key.keysym.sym in (sdl2.SDLK_ESCAPE, sdl2.SDLK_q):
self.running = False
def render(self):
self._draw_background()
self._draw_waveform()
self._draw_spectrum()
self._draw_level_meter()
self.renderer.present()
self._update_title()
def run(self):
try:
while self.running:
self._handle_events()
self._pull_audio()
self.render()
finally:
self.close()
def close(self):
if getattr(self, "audio_device", 0):
sdl2.SDL_PauseAudioDevice(self.audio_device, 1)
sdl2.SDL_ClearQueuedAudio(self.audio_device)
sdl2.SDL_CloseAudioDevice(self.audio_device)
self.audio_device = 0
sdl2.SDL_Quit()
def parse_args():
parser = argparse.ArgumentParser(description="Small SDL2 microphone input visualizer")
parser.add_argument("--list-devices", action="store_true", help="List available microphone capture devices and exit")
parser.add_argument("--device-index", type=int, help="Capture device index to open")
parser.add_argument("--width", type=int, default=960, help="Window width")
parser.add_argument("--height", type=int, default=540, help="Window height")
parser.add_argument("--history-seconds", type=float, default=1.5, help="Seconds of waveform history to keep on screen")
parser.add_argument("--sample-rate", type=int, default=48000, help="Preferred microphone sample rate")
parser.add_argument("--buffer-samples", type=int, default=1024, help="Requested SDL capture buffer size in samples")
return parser.parse_args()
def main():
args = parse_args()
if args.list_devices:
devices = list_capture_devices()
if not devices:
print("No microphone capture devices detected.")
return 0
for index, name in enumerate(devices):
print(f"{index}: {name}")
return 0
visualizer = MicVisualizer(
width=args.width,
height=args.height,
device_index=args.device_index,
history_seconds=max(0.25, float(args.history_seconds)),
sample_rate=max(8000, int(args.sample_rate)),
buffer_samples=max(128, int(args.buffer_samples)),
)
visualizer.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Loading…
Cancel
Save