|
|
|
|
@ -31,8 +31,18 @@ def list_capture_devices():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
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 |
|
|
|
|
@ -42,30 +52,62 @@ class MicVisualizer:
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
self.window = sdl2.ext.Window("Mic Visualizer", size=(self.width, self.height)) |
|
|
|
|
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.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._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) |
|
|
|
|
|
|
|
|
|
sdl2.SDL_PauseAudioDevice(self.audio_device, 0) |
|
|
|
|
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: |
|
|
|
|
@ -74,13 +116,19 @@ class MicVisualizer:
|
|
|
|
|
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) |
|
|
|
|
desired = sdl2.SDL_AudioSpec( |
|
|
|
|
freq=int(sample_rate), |
|
|
|
|
aformat=sdl2.AUDIO_S16SYS, |
|
|
|
|
channels=1, |
|
|
|
|
samples=int(buffer_samples), |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
obtained = sdl2.SDL_AudioSpec() |
|
|
|
|
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) |
|
|
|
|
@ -126,6 +174,11 @@ class MicVisualizer:
|
|
|
|
|
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 |
|
|
|
|
@ -239,9 +292,34 @@ class MicVisualizer:
|
|
|
|
|
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" |
|
|
|
|
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: |
|
|
|
|
@ -249,12 +327,24 @@ class MicVisualizer:
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
@ -273,6 +363,12 @@ class MicVisualizer:
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -285,6 +381,8 @@ def parse_args():
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -306,6 +404,8 @@ def main():
|
|
|
|
|
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 |
|
|
|
|
|