#!/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())