Files
mice/tools/mic_visualizer.py
T

415 lines
16 KiB
Python

#!/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,
fullscreen=False,
hide_cursor=False,
):
if sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO | sdl2.SDL_INIT_AUDIO | sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER) != 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.fullscreen = fullscreen
self.game_controller = None
self.joystick = None
self.capture_error = None
self.audio_device = 0
self.sample_rate = max(8000, int(sample_rate))
self.channels = 1
self.sample_width = ctypes.sizeof(ctypes.c_int16)
self.bytes_per_frame = self.sample_width * self.channels
window_flags = sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP if self.fullscreen else 0
self.window = sdl2.ext.Window("Mic Visualizer", size=(self.width, self.height), flags=window_flags)
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.sprite_factory = sdl2.ext.SpriteFactory(renderer=self.renderer)
self.font = sdl2.ext.FontManager(font_path="assets/decterm.ttf", size=20)
if hide_cursor:
sdl2.SDL_ShowCursor(sdl2.SDL_DISABLE)
self._open_input_device()
self.device_name = "No capture device"
try:
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.bytes_per_frame = self.sample_width * self.channels
except RuntimeError as exc:
self.capture_error = str(exc)
self.waveform = np.zeros(max(512, int(self.sample_rate * self.history_seconds)), dtype=np.float32)
self.spectrum = np.zeros(64, dtype=np.float32)
if self.audio_device:
sdl2.SDL_PauseAudioDevice(self.audio_device, 0)
self._update_title(force=True)
def _open_input_device(self):
joystick_count = int(sdl2.SDL_NumJoysticks())
if joystick_count <= 0:
return
if hasattr(sdl2, "SDL_IsGameController") and sdl2.SDL_IsGameController(0):
controller = sdl2.SDL_GameControllerOpen(0)
if controller:
self.game_controller = controller
return
joystick = sdl2.SDL_JoystickOpen(0)
if joystick:
self.joystick = joystick
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(
freq=int(sample_rate),
aformat=sdl2.AUDIO_S16SYS,
channels=1,
samples=int(buffer_samples),
)
obtained = sdl2.SDL_AudioSpec(
freq=int(sample_rate),
aformat=sdl2.AUDIO_S16SYS,
channels=1,
samples=int(buffer_samples),
)
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):
if not self.audio_device:
self.level *= 0.96
self.peak *= 0.94
self.spectrum *= 0.97
return
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))
if self.capture_error:
title = f"Mic Visualizer | {self.capture_error} | Esc/Q/Start/B quits"
else:
title = (
f"Mic Visualizer | {self.device_name} | level {level_percent}% | peak {peak_percent}%"
" | Esc/Q/Start/B quits"
)
sdl2.SDL_SetWindowTitle(self.window.window, title.encode("utf-8"))
def _draw_status_text(self):
lines = []
if self.capture_error:
lines.append("No microphone capture device detected")
lines.append(self.capture_error)
else:
lines.append(f"Input: {self.device_name}")
lines.append(f"Level: {int(round(min(1.0, self.level * 4.0) * 100))}%")
lines.append("Esc / Q / B / Start to quit")
y = 18
for line in lines:
sprite = self.sprite_factory.from_text(line, color=sdl2.ext.Color(230, 238, 248), fontmanager=self.font)
bg = sdl2.SDL_Rect(14, y - 4, sprite.size[0] + 12, sprite.size[1] + 8)
self._set_color((10, 14, 22, 220))
sdl2.SDL_RenderFillRect(self.sdl_renderer, ctypes.byref(bg))
self.renderer.copy(sprite, dstrect=sdl2.SDL_Rect(20, y, *sprite.size))
y += sprite.size[1] + 12
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
elif hasattr(sdl2, "SDL_CONTROLLERBUTTONDOWN") and event.type == sdl2.SDL_CONTROLLERBUTTONDOWN:
if event.cbutton.button in (
sdl2.SDL_CONTROLLER_BUTTON_B,
sdl2.SDL_CONTROLLER_BUTTON_START,
sdl2.SDL_CONTROLLER_BUTTON_BACK,
sdl2.SDL_CONTROLLER_BUTTON_GUIDE,
):
self.running = False
elif event.type == sdl2.SDL_JOYBUTTONDOWN:
if event.jbutton.button in (0, 1, 6, 7, 8, 9):
self.running = False
def render(self):
self._draw_background()
self._draw_waveform()
self._draw_spectrum()
self._draw_level_meter()
self._draw_status_text()
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
if self.game_controller is not None:
sdl2.SDL_GameControllerClose(self.game_controller)
self.game_controller = None
if self.joystick is not None:
sdl2.SDL_JoystickClose(self.joystick)
self.joystick = None
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")
parser.add_argument("--fullscreen", action="store_true", help="Open in fullscreen desktop mode")
parser.add_argument("--hide-cursor", action="store_true", help="Hide the mouse cursor while visualizing")
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)),
fullscreen=args.fullscreen,
hide_cursor=args.hide_cursor,
)
visualizer.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())