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.
This commit is contained in:
@@ -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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 683 KiB |
+1
-1
@@ -3,7 +3,7 @@
|
||||
<game>
|
||||
<path>./mice.sh</path>
|
||||
<name>Mice!</name>
|
||||
<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.</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>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 683 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 683 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user