Browse Source

Add launcher script for microphone visualizer with gamepad support

master
Matteo Benedetto 1 month ago
parent
commit
dd82ccc087
  1. 1
      README.md
  2. 76
      mice_mic.sh
  3. 140
      tools/mic_visualizer.py

1
README.md

@ -24,6 +24,7 @@ A small SDL2 microphone visualizer is available in `tools/mic_visualizer.py`.
- List capture devices: `python tools/mic_visualizer.py --list-devices`
- Open the default microphone: `python tools/mic_visualizer.py`
- Open a specific input: `python tools/mic_visualizer.py --device-index 1`
- On muOS, use `mice_mic.sh` as a launcher in `ROMS/Ports` and it will run fullscreen with gamepad quit support.
## Engine Architecture

76
mice_mic.sh

@ -0,0 +1,76 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
log() {
printf '[mice-mic] %s\n' "$*"
}
find_game_dir() {
for candidate in \
"$SCRIPT_DIR/mice" \
"$SCRIPT_DIR" \
/mnt/mmc/ports/mice \
/roms/ports/mice \
"$HOME/mice-current" \
/root/mice \
"$HOME/mice"; do
if [ -f "$candidate/rats.py" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
find_python() {
for candidate in \
"$GAMEDIR/.venv/bin/python" \
"$HOME/miniconda3/bin/python" \
/root/miniconda3/bin/python \
/usr/bin/python3 \
/usr/bin/python; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
GAMEDIR=$(find_game_dir)
PYTHONBIN=$(find_python)
mkdir -p "$GAMEDIR/logs"
LOGFILE="$GAMEDIR/logs/mic_visualizer.log"
exec >>"$LOGFILE" 2>&1
log "script_dir=$SCRIPT_DIR"
log "game_dir=$GAMEDIR"
log "python=$PYTHONBIN"
export MICE_PROJECT_ROOT="$GAMEDIR"
set -- --fullscreen --hide-cursor
if [ -n "${MICE_MIC_DEVICE_INDEX:-}" ]; then
set -- "$@" --device-index "$MICE_MIC_DEVICE_INDEX"
log "device_index=$MICE_MIC_DEVICE_INDEX"
fi
if [ "${MICE_MIC_LIST_DEVICES:-0}" = "1" ]; then
set -- --list-devices
log "list_devices=1"
fi
if [ -n "${MICE_MIC_EXTRA_ARGS:-}" ]; then
# shellcheck disable=SC2086
set -- "$@" ${MICE_MIC_EXTRA_ARGS}
log "extra_args=$MICE_MIC_EXTRA_ARGS"
fi
cd "$GAMEDIR"
log "argv=$*"
exec "$PYTHONBIN" tools/mic_visualizer.py "$@"

140
tools/mic_visualizer.py

@ -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

Loading…
Cancel
Save