- 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
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 683 KiB |
|
After Width: | Height: | Size: 683 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 683 KiB |
|
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()) |
||||