415 lines
16 KiB
Python
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()) |