Add launcher script for microphone visualizer with gamepad support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Executable
+76
@@ -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 "$@"
|
||||
+120
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user