From d71fcbab255edb5670aef27bc374a29b73f30aee Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Sat, 30 May 2026 15:11:07 -0600 Subject: [PATCH] round from from Claude Opus 4.8 --- skills-lock.json | 23 +++ soundboard/README.md | 98 +++++++++ soundboard/audio_engine.py | 254 +++++++++++++++++++++++ soundboard/board.py | 105 ++++++++++ soundboard/config.py | 72 +++++++ soundboard/main.py | 283 ++++++++++++++++++++++++++ soundboard/requirements.txt | 12 ++ soundboard/sounds/.gitkeep | 0 soundboard/tests/test_audio_engine.py | 72 +++++++ soundboard/tests/test_board.py | 100 +++++++++ soundboard/tests/test_config.py | 66 ++++++ soundboard_plan.md | 209 +++++++++++++++++++ 12 files changed, 1294 insertions(+) create mode 100644 skills-lock.json create mode 100644 soundboard/README.md create mode 100644 soundboard/audio_engine.py create mode 100644 soundboard/board.py create mode 100644 soundboard/config.py create mode 100644 soundboard/main.py create mode 100644 soundboard/requirements.txt create mode 100644 soundboard/sounds/.gitkeep create mode 100644 soundboard/tests/test_audio_engine.py create mode 100644 soundboard/tests/test_board.py create mode 100644 soundboard/tests/test_config.py create mode 100644 soundboard_plan.md diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..1e4d429 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "skills": { + "caveman": { + "source": "juliusbrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman/SKILL.md", + "computedHash": "dfbf85749fd474feeb0bbe60c779795ecd5dbec0083299b56e68916bc3ddd8c9" + }, + "handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/handoff/SKILL.md", + "computedHash": "1a78d774f8a59db5daa6e65e20a6596872fa8cde769f9a6e3a09b678dd5ae8cc" + }, + "make-interfaces-feel-better": { + "source": "jakubkrehel/make-interfaces-feel-better", + "sourceType": "github", + "skillPath": "skills/make-interfaces-feel-better/SKILL.md", + "computedHash": "54b6e221bbe4e2d1a8461c3f9ecabe69daf891f964e9ee5e699c323daa018f2e" + } + } +} diff --git a/soundboard/README.md b/soundboard/README.md new file mode 100644 index 0000000..bc6ae53 --- /dev/null +++ b/soundboard/README.md @@ -0,0 +1,98 @@ +# Soundboard + +A cross-platform desktop soundboard. It plays audio files through a **virtual audio +device** so the sounds show up as a *microphone* in other apps (Discord, OBS, Teams, …). + +- **Windows:** routes through [VB-Cable](https://vb-audio.com/Cable). +- **Linux:** routes through a PulseAudio / PipeWire **null sink**. + +Sounds **overlap** (multiple can play at once), you can add/remove/rename buttons at +runtime, and everything persists to `config.json`. + +--- + +## Install + +```bash +pip install -r requirements.txt +``` + +You also need: + +- **ffmpeg** on `PATH` (for MP3/OGG/M4A decoding via pydub) + - Windows: `winget install Gyan.FFmpeg` + - Debian/Ubuntu: `sudo apt install ffmpeg` +- **tkinter** (bundled with CPython; on Linux you may need `sudo apt install python3-tk`) + +## Run + +```bash +python main.py +``` + +--- + +## Virtual device setup + +### Windows (VB-Cable) +1. Install VB-Cable from https://vb-audio.com/Cable and **reboot**. +2. In the soundboard, pick **"CABLE Input (VB-Audio Virtual Cable)"** as the output (it + auto-selects if found). +3. In Discord / OBS / Teams, set the mic input to **"CABLE Output (VB-Audio Virtual Cable)"**. + +### Linux (PipeWire / PulseAudio null sink) +1. Create a null sink (it appears as an output device named like *"Null Output"*): + ```bash + pactl load-module module-null-sink sink_name=soundboard \ + sink_properties=device.description=Soundboard + ``` + (Works under both PulseAudio and PipeWire's pipewire-pulse.) +2. In the soundboard, pick the **Soundboard / Null Output** device (auto-selected if found). +3. In your target app, choose the sink's **monitor** as the microphone: + *"Monitor of Soundboard"*. +4. To remove the sink later: `pactl unload-module module-null-sink` (or unload by index). + +--- + +## Usage + +- **Add Sound** — file picker (`.wav .mp3 .ogg .flac .aiff .m4a .aac`). The file is + *referenced*, not copied. +- **Left-click** a button — play it (overlaps with anything already playing). The button + turns green while it's sounding. +- **Right-click** a button — Rename / Stop this sound / Remove. +- **Volume** slider — master volume, applied live to playing sounds. +- **Stop All** — halt everything immediately. + +All state (device, volume, buttons) is saved to `config.json` next to `main.py`. + +--- + +## Architecture + +| File | Responsibility | +|---|---| +| `audio_engine.py` | Device discovery, cross-platform auto-select, decode+normalize, **overlapping** playback (one thread + `OutputStream` per sound). No GUI deps. | +| `config.py` | Defensive JSON load/save (atomic write; corrupt/missing → defaults). Pure. | +| `board.py` | Button state: add/remove/rename, id allocation, persistence. Pure. | +| `main.py` | tkinter GUI wiring board + engine; marshals playback callbacks to the UI thread. | + +`config.py` and `board.py` are import-free of audio/GUI libraries so they're unit-testable +headlessly. `audio_engine.py` lazily imports `sounddevice`/`soundfile`/`pydub` so the pure +logic and tests run without audio libraries installed. + +### Notable deviations from the original plan +- The plan's `sd.play()` example was a singleton and **cannot** overlap sounds. Replaced + with per-sound `OutputStream`s on dedicated threads. +- Both decode paths are normalized to a common channel layout (`sounddevice` won't + resample/remix), preventing errors/pitch-shift on mixed files. +- Added a playback-finished signal so the "playing" indicator clears correctly. + +## Tests + +```bash +python -m unittest discover -s tests -v +``` + +`config`/`board` tests need only the stdlib. The `audio_engine` tests +(`match_channels`, `auto_select_device`) require `numpy` and are skipped if it's absent. diff --git a/soundboard/audio_engine.py b/soundboard/audio_engine.py new file mode 100644 index 0000000..ef2b7be --- /dev/null +++ b/soundboard/audio_engine.py @@ -0,0 +1,254 @@ +"""Audio engine: device discovery, decoding/normalization, and overlapping playback. + +Design decisions (see soundboard_plan.md review): + * True overlap: each play spawns its own dedicated thread + sounddevice.OutputStream, + so sounds mix instead of cutting each other off. (The plan's sd.play() singleton + cannot do this.) + * Cross-platform virtual device: auto-selection prefers VB-Cable on Windows and a + null-sink / virtual device on Linux. + * Live volume: the engine's volume is read on every audio block, so moving the slider + affects already-playing sounds. + +Heavy/native imports (sounddevice, soundfile, pydub) are loaded lazily so that the pure +logic in config.py / board.py and the unit tests can run without audio libraries present. +""" + +from __future__ import annotations + +import os +import platform +import threading +from typing import Callable, Optional + +import numpy as np + +# Formats decodable directly by libsndfile (soundfile); everything else goes via pydub/ffmpeg. +_SOUNDFILE_EXTS = {"wav", "flac", "aiff", "aif"} +SUPPORTED_EXTS = _SOUNDFILE_EXTS | {"mp3", "ogg", "m4a", "aac"} + +# Priority order for auto-selecting a virtual output device, case-insensitive substring match. +# Works for Windows (VB-Cable) and Linux (PulseAudio/PipeWire null sink) alike. +_AUTO_DEVICE_PRIORITY = ("cable input", "vb-audio", "vb-cable", "virtual", "null") + +DEFAULT_TARGET_CHANNELS = 2 + + +# --------------------------------------------------------------------------- decoding + + +def _ext(file_path: str) -> str: + return os.path.splitext(file_path)[1].lower().lstrip(".") + + +def match_channels(data: np.ndarray, target_channels: int) -> np.ndarray: + """Coerce a 2-D (frames, channels) float array to ``target_channels`` channels. + + Mono -> duplicated across channels; extra channels -> truncated; in-between -> the + first channel is tiled to fill. Keeps overlapping streams from erroring on a device + that expects a fixed channel count (sounddevice does not up/down-mix for us). + """ + if data.ndim == 1: + data = data.reshape(-1, 1) + have = data.shape[1] + if have == target_channels: + return data + if have == 1: + return np.tile(data, (1, target_channels)) + if have > target_channels: + return data[:, :target_channels].copy() + # 1 < have < target: pad by repeating the first channel + pad = np.tile(data[:, :1], (1, target_channels - have)) + return np.concatenate([data, pad], axis=1) + + +def load_audio(file_path: str, target_channels: int = DEFAULT_TARGET_CHANNELS): + """Load any supported format -> (float32 ndarray shape (frames, target_channels), samplerate). + + Both code paths are normalized to the same dtype and channel layout so that mixed + files don't error or pitch-shift when streamed on one device. + """ + ext = _ext(file_path) + if ext in _SOUNDFILE_EXTS: + import soundfile as sf + + data, sr = sf.read(file_path, dtype="float32", always_2d=True) + else: + from pydub import AudioSegment + + seg = AudioSegment.from_file(file_path) + sr = seg.frame_rate + samples = np.array(seg.get_array_of_samples(), dtype=np.float32) + samples = samples.reshape((-1, seg.channels)) + # Normalize integer PCM to [-1, 1] using the actual sample width. + max_val = float(1 << (8 * seg.sample_width - 1)) + data = samples / max_val + + data = match_channels(np.asarray(data, dtype=np.float32), target_channels) + return data, int(sr) + + +# ----------------------------------------------------------------- device discovery + + +def list_output_devices(): + """Return [{'index': int, 'name': str, 'channels': int}, ...] for output-capable devices.""" + import sounddevice as sd + + out = [] + for i, dev in enumerate(sd.query_devices()): + if dev.get("max_output_channels", 0) > 0: + out.append( + { + "index": i, + "name": dev["name"], + "channels": dev["max_output_channels"], + } + ) + return out + + +def auto_select_device(devices) -> Optional[dict]: + """Pick the best virtual-output device from list_output_devices() output, or None. + + Pure function (no audio imports) so it is unit-testable with plain dicts. + """ + for needle in _AUTO_DEVICE_PRIORITY: + for dev in devices: + if needle in dev["name"].lower(): + return dev + return None + + +# ------------------------------------------------------------------------ playback + + +class _Playback: + """A single sound playing on its own stream in its own thread.""" + + def __init__(self, engine: "AudioEngine", pid: int, file_path: str, + on_start: Optional[Callable], on_finish: Optional[Callable]): + self._engine = engine + self.pid = pid + self.file_path = file_path + self._on_start = on_start + self._on_finish = on_finish + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, name=f"playback-{pid}", daemon=True) + + def start(self): + self._thread.start() + + def stop(self): + self._stop.set() + + def _run(self): + import sounddevice as sd + + try: + data, sr = load_audio(self.file_path, self._engine.target_channels) + except Exception as exc: # decoding failed — report and bail without crashing GUI + self._engine._remove(self.pid) + if self._on_finish: + self._on_finish(self.pid, exc) + return + + if self._on_start: + self._on_start(self.pid) + + error: Optional[Exception] = None + blocksize = 2048 + try: + with sd.OutputStream( + samplerate=sr, + device=self._engine.device_index, + channels=data.shape[1], + dtype="float32", + ) as stream: + i = 0 + n = len(data) + while i < n and not self._stop.is_set(): + chunk = data[i : i + blocksize] * self._engine.volume + stream.write(np.ascontiguousarray(chunk, dtype=np.float32)) + i += blocksize + except Exception as exc: + error = exc + finally: + self._engine._remove(self.pid) + if self._on_finish: + self._on_finish(self.pid, error) + + +class AudioEngine: + """Owns the selected output device, master volume, and the set of active playbacks. + + Callbacks (on_start/on_finish) fire from playback worker threads — GUI callers must + marshal back to the UI thread themselves (e.g. tkinter ``root.after``). + """ + + def __init__(self, device_name: Optional[str] = None, volume: float = 1.0): + self.volume = float(volume) + self.device_name: Optional[str] = None + self.device_index: Optional[int] = None + self.target_channels = DEFAULT_TARGET_CHANNELS + self._playbacks: dict[int, _Playback] = {} + self._counter = 0 + self._lock = threading.Lock() + if device_name: + self.set_device(device_name) + + # ---- configuration ----------------------------------------------------- + def set_device(self, device_name: Optional[str]): + """Resolve a device by name substring and remember its index + channel count.""" + self.device_name = device_name + if not device_name: + self.device_index = None + self.target_channels = DEFAULT_TARGET_CHANNELS + return + for dev in list_output_devices(): + if device_name.lower() in dev["name"].lower(): + self.device_index = dev["index"] + self.target_channels = min(DEFAULT_TARGET_CHANNELS, max(1, dev["channels"])) + return + raise ValueError(f"Output device not found: {device_name!r}") + + def set_volume(self, volume: float): + """Set master volume in [0.0, 1.0]; applied live to playing sounds.""" + self.volume = max(0.0, min(1.0, float(volume))) + + # ---- playback ---------------------------------------------------------- + def play(self, file_path: str, on_start=None, on_finish=None) -> int: + """Start a sound (non-blocking, overlapping). Returns a playback id.""" + with self._lock: + pid = self._counter + self._counter += 1 + pb = _Playback(self, pid, file_path, on_start, on_finish) + self._playbacks[pid] = pb + pb.start() + return pid + + def stop(self, pid: int): + with self._lock: + pb = self._playbacks.get(pid) + if pb: + pb.stop() + + def stop_file(self, file_path: str): + """Stop every active playback of a given file path.""" + with self._lock: + targets = [pb for pb in self._playbacks.values() if pb.file_path == file_path] + for pb in targets: + pb.stop() + + def stop_all(self): + with self._lock: + targets = list(self._playbacks.values()) + for pb in targets: + pb.stop() + + def active_count(self) -> int: + with self._lock: + return len(self._playbacks) + + def _remove(self, pid: int): + with self._lock: + self._playbacks.pop(pid, None) diff --git a/soundboard/board.py b/soundboard/board.py new file mode 100644 index 0000000..c5e391f --- /dev/null +++ b/soundboard/board.py @@ -0,0 +1,105 @@ +"""Board state: the ordered collection of sound buttons. + +Pure/headless (no audio or GUI imports) so add/remove/rename/persistence are unit-testable. +The GUI observes changes via an optional ``on_change`` callback and renders from +``board.buttons``. +""" + +from __future__ import annotations + +import os +from typing import Any, Callable, Optional + +from config import default_config, load_config, save_config + + +def _base_label(file_path: str) -> str: + """Default button label from a file path: the base name without extension.""" + return os.path.splitext(os.path.basename(file_path))[0] + + +class Board: + def __init__(self, config_path: str): + self.config_path = config_path + cfg = load_config(config_path) + self.output_device: Optional[str] = cfg["output_device"] + self.volume: int = cfg["volume"] + self.buttons: list[dict[str, Any]] = cfg["buttons"] + self._next_id = self._compute_next_id() + self.on_change: Optional[Callable[[], None]] = None + + # ---- id allocation ----------------------------------------------------- + def _compute_next_id(self) -> int: + """Next free numeric suffix for ids of the form ``btn_NNN``.""" + highest = 0 + for b in self.buttons: + bid = b.get("id", "") + if bid.startswith("btn_"): + try: + highest = max(highest, int(bid[4:])) + except ValueError: + pass + return highest + 1 + + def _new_id(self) -> str: + bid = f"btn_{self._next_id:03d}" + self._next_id += 1 + return bid + + # ---- internal ---------------------------------------------------------- + def _index_of(self, button_id: str) -> int: + for i, b in enumerate(self.buttons): + if b["id"] == button_id: + return i + raise KeyError(button_id) + + def _changed(self): + self.save() + if self.on_change: + self.on_change() + + # ---- mutations --------------------------------------------------------- + def add_button(self, file_path: str, label: Optional[str] = None) -> dict[str, Any]: + """Register a sound file as a new button (file is referenced, never copied).""" + button = { + "id": self._new_id(), + "label": label if label else _base_label(file_path), + "file_path": file_path, + } + self.buttons.append(button) + self._changed() + return button + + def remove_button(self, button_id: str) -> None: + del self.buttons[self._index_of(button_id)] + self._changed() + + def rename_button(self, button_id: str, new_label: str) -> None: + new_label = new_label.strip() + if not new_label: + raise ValueError("label must not be empty") + self.buttons[self._index_of(button_id)]["label"] = new_label + self._changed() + + def get_button(self, button_id: str) -> dict[str, Any]: + return self.buttons[self._index_of(button_id)] + + # ---- settings ---------------------------------------------------------- + def set_output_device(self, device_name: Optional[str]) -> None: + self.output_device = device_name + self._changed() + + def set_volume(self, volume: int) -> None: + self.volume = int(max(0, min(100, volume))) + self._changed() + + # ---- persistence ------------------------------------------------------- + def to_config(self) -> dict[str, Any]: + cfg = default_config() + cfg["output_device"] = self.output_device + cfg["volume"] = self.volume + cfg["buttons"] = [dict(b) for b in self.buttons] + return cfg + + def save(self) -> None: + save_config(self.config_path, self.to_config()) diff --git a/soundboard/config.py b/soundboard/config.py new file mode 100644 index 0000000..5f54b23 --- /dev/null +++ b/soundboard/config.py @@ -0,0 +1,72 @@ +"""Load/save the soundboard configuration as JSON. + +Pure/headless: no audio or GUI imports, so it is fully unit-testable. Reads are +defensive — a missing or corrupt file yields a valid default config rather than crashing. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +DEFAULT_CONFIG: dict[str, Any] = { + "output_device": None, # substring of the device name, e.g. "CABLE Input" + "volume": 80, # 0-100 (UI scale) + "buttons": [], # list of {"id", "label", "file_path"} +} + + +def default_config() -> dict[str, Any]: + """A fresh deep copy of the default config.""" + return json.loads(json.dumps(DEFAULT_CONFIG)) + + +def _coerce(raw: Any) -> dict[str, Any]: + """Merge a parsed object onto the defaults, dropping anything malformed.""" + cfg = default_config() + if not isinstance(raw, dict): + return cfg + + if isinstance(raw.get("output_device"), str): + cfg["output_device"] = raw["output_device"] + + vol = raw.get("volume") + if isinstance(vol, (int, float)): + cfg["volume"] = int(max(0, min(100, vol))) + + buttons = [] + if isinstance(raw.get("buttons"), list): + for b in raw["buttons"]: + if not isinstance(b, dict): + continue + bid = b.get("id") + label = b.get("label") + path = b.get("file_path") + if isinstance(bid, str) and isinstance(label, str) and isinstance(path, str): + buttons.append({"id": bid, "label": label, "file_path": path}) + cfg["buttons"] = buttons + return cfg + + +def load_config(path: str) -> dict[str, Any]: + """Load config from ``path``; return a valid default if missing or corrupt.""" + if not os.path.exists(path): + return default_config() + try: + with open(path, "r", encoding="utf-8") as fh: + raw = json.load(fh) + except (OSError, ValueError): + return default_config() + return _coerce(raw) + + +def save_config(path: str, config: dict[str, Any]) -> None: + """Atomically write config to ``path`` (write temp + replace).""" + config = _coerce(config) + directory = os.path.dirname(os.path.abspath(path)) + os.makedirs(directory, exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(config, fh, indent=2) + os.replace(tmp, path) diff --git a/soundboard/main.py b/soundboard/main.py new file mode 100644 index 0000000..1806850 --- /dev/null +++ b/soundboard/main.py @@ -0,0 +1,283 @@ +"""Tkinter GUI wiring the board state and audio engine together. + +Cross-platform: auto-selects VB-Cable (Windows) or a null-sink/virtual device (Linux). +Playback is overlapping and non-blocking; the engine runs each sound on its own thread, +and finish/start callbacks are marshaled back to the Tk thread via ``root.after``. +""" + +from __future__ import annotations + +import os +import tkinter as tk +from tkinter import filedialog, messagebox, simpledialog, ttk + +import audio_engine +from board import Board + +CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +GRID_COLUMNS = 4 + +FILE_TYPES = [ + ("Audio files", "*.wav *.mp3 *.ogg *.flac *.aiff *.aif *.m4a *.aac"), + ("All files", "*.*"), +] + +IDLE_BG = "#3b4252" +PLAYING_BG = "#a3be8c" +FG = "#eceff4" + + +class SoundboardApp: + def __init__(self, root: tk.Tk): + self.root = root + self.root.title("Soundboard") + self.root.geometry("640x480") + self.root.minsize(420, 320) + + self.board = Board(CONFIG_PATH) + self.engine = audio_engine.AudioEngine(volume=self.board.volume / 100.0) + + # button_id -> set of active playback ids (for the "playing" indicator) + self._active: dict[str, set[int]] = {} + self._button_widgets: dict[str, tk.Button] = {} + + self._devices = self._safe_list_devices() + self._build_toolbar() + self._build_grid_area() + + self._init_device_selection() + self.board.on_change = self._rebuild_grid + self._rebuild_grid() + + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + # ------------------------------------------------------------------ setup + def _safe_list_devices(self): + try: + return audio_engine.list_output_devices() + except Exception as exc: # audio backend unavailable — degrade gracefully + messagebox.showwarning( + "Audio unavailable", + f"Could not query audio output devices:\n{exc}\n\n" + "Playback will not work until this is resolved.", + ) + return [] + + def _build_toolbar(self): + bar = ttk.Frame(self.root, padding=(8, 6)) + bar.pack(side=tk.TOP, fill=tk.X) + + ttk.Label(bar, text="Output:").pack(side=tk.LEFT) + self.device_var = tk.StringVar() + self.device_combo = ttk.Combobox( + bar, textvariable=self.device_var, state="readonly", width=34, + values=[d["name"] for d in self._devices], + ) + self.device_combo.pack(side=tk.LEFT, padx=(4, 8)) + self.device_combo.bind("<>", self._on_device_change) + + ttk.Button(bar, text="Add Sound", command=self._on_add).pack(side=tk.LEFT) + ttk.Button(bar, text="Stop All", command=self.engine.stop_all).pack(side=tk.LEFT, padx=4) + + ttk.Label(bar, text="Volume").pack(side=tk.LEFT, padx=(12, 2)) + self.volume_var = tk.IntVar(value=self.board.volume) + self.volume_scale = ttk.Scale( + bar, from_=0, to=100, orient=tk.HORIZONTAL, + command=self._on_volume_move, length=120, + ) + self.volume_scale.set(self.board.volume) + self.volume_scale.pack(side=tk.LEFT) + self.volume_label = ttk.Label(bar, text=f"{self.board.volume}%", width=4) + self.volume_label.pack(side=tk.LEFT) + # Persist volume only when the user releases the slider. + self.volume_scale.bind("", self._on_volume_commit) + + def _build_grid_area(self): + outer = ttk.Frame(self.root) + outer.pack(fill=tk.BOTH, expand=True) + canvas = tk.Canvas(outer, highlightthickness=0) + scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview) + self.grid_frame = ttk.Frame(canvas, padding=8) + self.grid_frame.bind( + "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + canvas.create_window((0, 0), window=self.grid_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + status = ttk.Frame(self.root) + status.pack(side=tk.BOTTOM, fill=tk.X) + self.status_var = tk.StringVar(value="Ready") + ttk.Label(status, textvariable=self.status_var, anchor="w", padding=(8, 2)).pack( + fill=tk.X + ) + + def _init_device_selection(self): + saved = self.board.output_device + chosen = None + if saved and any(saved.lower() in d["name"].lower() for d in self._devices): + chosen = next(d for d in self._devices if saved.lower() in d["name"].lower()) + if chosen is None: + chosen = audio_engine.auto_select_device(self._devices) + if chosen is not None: + self.device_var.set(chosen["name"]) + self._apply_device(chosen["name"]) + + # ------------------------------------------------------------- callbacks + def _on_device_change(self, _event=None): + self._apply_device(self.device_var.get()) + + def _apply_device(self, name: str): + try: + self.engine.set_device(name) + except ValueError as exc: + messagebox.showerror("Device error", str(exc)) + return + self.board.set_output_device(name) + self.status_var.set(f"Output: {name}") + + def _on_volume_move(self, value): + vol = int(float(value)) + self.volume_label.config(text=f"{vol}%") + self.engine.set_volume(vol / 100.0) + + def _on_volume_commit(self, _event=None): + self.board.set_volume(int(self.volume_scale.get())) + + def _on_add(self): + path = filedialog.askopenfilename(title="Add Sound", filetypes=FILE_TYPES) + if not path: + return + ext = os.path.splitext(path)[1].lower().lstrip(".") + if ext not in audio_engine.SUPPORTED_EXTS: + messagebox.showwarning("Unsupported", f"Unsupported file type: .{ext}") + return + self.board.add_button(path) # triggers _rebuild_grid via on_change + + # --------------------------------------------------------------- grid UI + def _rebuild_grid(self): + for child in self.grid_frame.winfo_children(): + child.destroy() + self._button_widgets.clear() + + for col in range(GRID_COLUMNS): + self.grid_frame.columnconfigure(col, weight=1, uniform="cells") + + if not self.board.buttons: + ttk.Label( + self.grid_frame, + text='No sounds yet — click "Add Sound" to get started.', + ).grid(row=0, column=0, columnspan=GRID_COLUMNS, pady=20) + return + + for idx, b in enumerate(self.board.buttons): + row, col = divmod(idx, GRID_COLUMNS) + playing = bool(self._active.get(b["id"])) + btn = tk.Button( + self.grid_frame, + text=b["label"], + wraplength=120, + height=3, + bg=PLAYING_BG if playing else IDLE_BG, + fg=FG, + activebackground=PLAYING_BG, + relief=tk.RAISED, + command=lambda bid=b["id"]: self._play_button(bid), + ) + btn.grid(row=row, column=col, sticky="nsew", padx=4, pady=4) + btn.bind("", lambda e, bid=b["id"]: self._show_context_menu(e, bid)) + self._button_widgets[b["id"]] = btn + + def _set_button_playing(self, button_id: str, playing: bool): + btn = self._button_widgets.get(button_id) + if btn is not None: + btn.config(bg=PLAYING_BG if playing else IDLE_BG) + + # ------------------------------------------------------------- playback + def _play_button(self, button_id: str): + try: + button = self.board.get_button(button_id) + except KeyError: + return + path = button["file_path"] + if not os.path.exists(path): + messagebox.showerror("Missing file", f"File not found:\n{path}") + return + if self.engine.device_index is None: + messagebox.showwarning("No device", "Select an output device first.") + return + + self.engine.play( + path, + on_start=lambda pid, bid=button_id: self.root.after(0, self._on_play_start, bid, pid), + on_finish=lambda pid, err, bid=button_id: self.root.after( + 0, self._on_play_finish, bid, pid, err + ), + ) + + def _on_play_start(self, button_id: str, pid: int): + self._active.setdefault(button_id, set()).add(pid) + self._set_button_playing(button_id, True) + + def _on_play_finish(self, button_id: str, pid: int, err): + active = self._active.get(button_id) + if active: + active.discard(pid) + if not active: + self._active.pop(button_id, None) + self._set_button_playing(button_id, False) + if err is not None: + self.status_var.set(f"Playback error: {err}") + + # ------------------------------------------------------- context menu + def _show_context_menu(self, event, button_id: str): + menu = tk.Menu(self.root, tearoff=0) + menu.add_command(label="Rename", command=lambda: self._rename(button_id)) + menu.add_command(label="Stop this sound", command=lambda: self._stop_button(button_id)) + menu.add_separator() + menu.add_command(label="Remove", command=lambda: self._remove(button_id)) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _rename(self, button_id: str): + try: + current = self.board.get_button(button_id)["label"] + except KeyError: + return + new = simpledialog.askstring("Rename", "New label:", initialvalue=current, parent=self.root) + if new and new.strip(): + self.board.rename_button(button_id, new) + + def _stop_button(self, button_id: str): + try: + self.engine.stop_file(self.board.get_button(button_id)["file_path"]) + except KeyError: + pass + + def _remove(self, button_id: str): + try: + label = self.board.get_button(button_id)["label"] + except KeyError: + return + if messagebox.askyesno("Remove", f'Remove "{label}" from the board?\n(The file stays on disk.)'): + self._stop_button(button_id) + self.board.remove_button(button_id) + + # ------------------------------------------------------------- shutdown + def _on_close(self): + self.engine.stop_all() + self.board.save() + self.root.destroy() + + +def main(): + root = tk.Tk() + SoundboardApp(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/soundboard/requirements.txt b/soundboard/requirements.txt new file mode 100644 index 0000000..ec17522 --- /dev/null +++ b/soundboard/requirements.txt @@ -0,0 +1,12 @@ +# Core audio +sounddevice +soundfile +numpy +pydub # MP3/OGG decoding (requires ffmpeg on PATH) + +# Notes: +# - ffmpeg must be installed and on PATH for MP3/OGG/etc. decoding via pydub. +# Windows: winget install Gyan.FFmpeg +# Debian/Ubuntu: sudo apt install ffmpeg +# - tkinter ships with CPython but may need a system package on Linux: +# Debian/Ubuntu: sudo apt install python3-tk diff --git a/soundboard/sounds/.gitkeep b/soundboard/sounds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/soundboard/tests/test_audio_engine.py b/soundboard/tests/test_audio_engine.py new file mode 100644 index 0000000..d1fc55b --- /dev/null +++ b/soundboard/tests/test_audio_engine.py @@ -0,0 +1,72 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + import numpy as np + import audio_engine + HAVE_NUMPY = True +except Exception: # numpy not installed — skip the array-shape tests + HAVE_NUMPY = False + + +@unittest.skipUnless(HAVE_NUMPY, "numpy required") +class TestMatchChannels(unittest.TestCase): + def test_mono_to_stereo(self): + mono = np.zeros((100, 1), dtype=np.float32) + out = audio_engine.match_channels(mono, 2) + self.assertEqual(out.shape, (100, 2)) + + def test_1d_treated_as_mono(self): + out = audio_engine.match_channels(np.zeros(50, dtype=np.float32), 2) + self.assertEqual(out.shape, (50, 2)) + + def test_stereo_unchanged(self): + st = np.zeros((10, 2), dtype=np.float32) + out = audio_engine.match_channels(st, 2) + self.assertEqual(out.shape, (10, 2)) + + def test_downmix_truncates(self): + five = np.zeros((10, 5), dtype=np.float32) + out = audio_engine.match_channels(five, 2) + self.assertEqual(out.shape, (10, 2)) + + def test_mono_values_duplicated(self): + mono = np.arange(4, dtype=np.float32).reshape(4, 1) + out = audio_engine.match_channels(mono, 2) + self.assertTrue(np.array_equal(out[:, 0], out[:, 1])) + + +@unittest.skipUnless(HAVE_NUMPY, "numpy required") +class TestAutoSelectDevice(unittest.TestCase): + def test_prefers_cable_input(self): + devices = [ + {"index": 0, "name": "Built-in Output", "channels": 2}, + {"index": 1, "name": "CABLE Input (VB-Audio Virtual Cable)", "channels": 2}, + ] + self.assertEqual(audio_engine.auto_select_device(devices)["index"], 1) + + def test_linux_null_sink(self): + devices = [ + {"index": 0, "name": "HDA Intel PCH", "channels": 2}, + {"index": 1, "name": "Null Output", "channels": 2}, + ] + self.assertEqual(audio_engine.auto_select_device(devices)["index"], 1) + + def test_none_when_no_virtual(self): + devices = [{"index": 0, "name": "Speakers", "channels": 2}] + self.assertIsNone(audio_engine.auto_select_device(devices)) + + def test_priority_order(self): + # "cable input" outranks "virtual" even when virtual appears first + devices = [ + {"index": 0, "name": "Some Virtual Thing", "channels": 2}, + {"index": 1, "name": "CABLE Input", "channels": 2}, + ] + self.assertEqual(audio_engine.auto_select_device(devices)["index"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/soundboard/tests/test_board.py b/soundboard/tests/test_board.py new file mode 100644 index 0000000..ccd9c46 --- /dev/null +++ b/soundboard/tests/test_board.py @@ -0,0 +1,100 @@ +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import config +from board import Board + + +class TestBoard(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.path = os.path.join(self.dir, "config.json") + + def _board(self): + return Board(self.path) + + def test_add_button_defaults_label_to_basename(self): + b = self._board() + btn = b.add_button("/sounds/airhorn.mp3") + self.assertEqual(btn["label"], "airhorn") + self.assertEqual(btn["id"], "btn_001") + self.assertEqual(len(b.buttons), 1) + + def test_add_button_custom_label(self): + b = self._board() + btn = b.add_button("/sounds/x.wav", label="Custom") + self.assertEqual(btn["label"], "Custom") + + def test_ids_increment_and_persist(self): + b = self._board() + b.add_button("/a.wav") + b.add_button("/b.wav") + self.assertEqual([x["id"] for x in b.buttons], ["btn_001", "btn_002"]) + # Reload: new ids continue after the highest existing one. + b2 = self._board() + btn = b2.add_button("/c.wav") + self.assertEqual(btn["id"], "btn_003") + + def test_remove_button(self): + b = self._board() + b.add_button("/a.wav") + btn = b.add_button("/b.wav") + b.remove_button("btn_001") + self.assertEqual([x["id"] for x in b.buttons], ["btn_002"]) + # persisted + self.assertEqual(len(self._board().buttons), 1) + + def test_remove_unknown_raises(self): + b = self._board() + with self.assertRaises(KeyError): + b.remove_button("nope") + + def test_rename_button(self): + b = self._board() + b.add_button("/a.wav") + b.rename_button("btn_001", " Renamed ") + self.assertEqual(b.get_button("btn_001")["label"], "Renamed") + + def test_rename_empty_raises(self): + b = self._board() + b.add_button("/a.wav") + with self.assertRaises(ValueError): + b.rename_button("btn_001", " ") + + def test_settings_persist(self): + b = self._board() + b.set_output_device("CABLE Input") + b.set_volume(42) + reloaded = self._board() + self.assertEqual(reloaded.output_device, "CABLE Input") + self.assertEqual(reloaded.volume, 42) + + def test_volume_clamped(self): + b = self._board() + b.set_volume(150) + self.assertEqual(b.volume, 100) + b.set_volume(-10) + self.assertEqual(b.volume, 0) + + def test_on_change_fires(self): + b = self._board() + calls = [] + b.on_change = lambda: calls.append(1) + b.add_button("/a.wav") + b.rename_button("btn_001", "z") + b.remove_button("btn_001") + self.assertEqual(len(calls), 3) + + def test_to_config_matches_schema(self): + b = self._board() + b.add_button("/a.wav", "A") + cfg = b.to_config() + self.assertEqual(set(cfg.keys()), set(config.default_config().keys())) + + +if __name__ == "__main__": + unittest.main() diff --git a/soundboard/tests/test_config.py b/soundboard/tests/test_config.py new file mode 100644 index 0000000..b8d6daa --- /dev/null +++ b/soundboard/tests/test_config.py @@ -0,0 +1,66 @@ +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import config + + +class TestConfig(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.path = os.path.join(self.dir, "config.json") + + def test_missing_file_returns_default(self): + cfg = config.load_config(self.path) + self.assertEqual(cfg, config.default_config()) + self.assertEqual(cfg["buttons"], []) + self.assertIsNone(cfg["output_device"]) + + def test_corrupt_file_returns_default(self): + with open(self.path, "w") as fh: + fh.write("{not valid json") + self.assertEqual(config.load_config(self.path), config.default_config()) + + def test_round_trip(self): + cfg = config.default_config() + cfg["output_device"] = "CABLE Input" + cfg["volume"] = 55 + cfg["buttons"] = [{"id": "btn_001", "label": "Air Horn", "file_path": "/x/a.mp3"}] + config.save_config(self.path, cfg) + loaded = config.load_config(self.path) + self.assertEqual(loaded, cfg) + + def test_save_is_atomic_no_tmp_left(self): + config.save_config(self.path, config.default_config()) + self.assertFalse(os.path.exists(self.path + ".tmp")) + self.assertTrue(os.path.exists(self.path)) + + def test_volume_clamped(self): + with open(self.path, "w") as fh: + json.dump({"volume": 999}, fh) + self.assertEqual(config.load_config(self.path)["volume"], 100) + + def test_malformed_buttons_dropped(self): + with open(self.path, "w") as fh: + json.dump( + { + "buttons": [ + {"id": "btn_001", "label": "ok", "file_path": "/a"}, + {"id": "btn_002", "label": "missing path"}, + "not a dict", + {"id": 5, "label": "bad id type", "file_path": "/b"}, + ] + }, + fh, + ) + buttons = config.load_config(self.path)["buttons"] + self.assertEqual(len(buttons), 1) + self.assertEqual(buttons[0]["id"], "btn_001") + + +if __name__ == "__main__": + unittest.main() diff --git a/soundboard_plan.md b/soundboard_plan.md new file mode 100644 index 0000000..638355c --- /dev/null +++ b/soundboard_plan.md @@ -0,0 +1,209 @@ +# Python Soundboard — Claude Code Build Plan + +## Overview + +A desktop soundboard application for Windows 11 that routes audio through VB-Cable so it +appears as a microphone input to other applications (Discord, OBS, Teams, etc.). Users can +add and remove audio files from the board at runtime via a simple GUI. + +--- + +## Tech Stack + +| Layer | Choice | Reason | +|---|---|---| +| Language | Python 3.11+ | Easy iteration, good audio library support | +| GUI | `tkinter` | Stdlib, no install needed, sufficient for this use case | +| Audio playback | `sounddevice` + `soundfile` | Device selection by name, supports WAV/FLAC natively | +| Audio decoding | `pydub` + `ffmpeg` | Converts MP3/OGG/etc. to raw PCM for sounddevice | +| Config persistence | `json` | Store board layout and device preference between sessions | + +### Install Requirements (`requirements.txt`) +``` +sounddevice +soundfile +pydub +numpy +``` +> ffmpeg must also be installed and on PATH (used by pydub for MP3 decoding). +> Recommend: `winget install Gyan.FFmpeg` + +--- + +## Project Structure + +``` +soundboard/ +├── main.py # Entry point, launches GUI +├── audio_engine.py # Handles device selection and playback +├── board.py # Board state: buttons, file mappings +├── config.py # Load/save config to JSON +├── config.json # Persisted state (auto-created on first run) +├── requirements.txt +└── sounds/ # Optional default folder for audio files +``` + +--- + +## Feature Spec + +### 1. Device Selection (Startup) +- On launch, scan available output devices via `sounddevice.query_devices()` +- Show a dropdown/listbox to select the output device +- Default: auto-select first device whose name contains `"CABLE Input"` (VB-Cable) +- Selection is saved to `config.json` and restored on next launch + +### 2. Soundboard Grid +- Display a grid of buttons (default: 4 columns, expandable rows) +- Each button shows: + - The audio file's base name (e.g. `airhorn.mp3`) + - A colored indicator when playing +- Clicking a button plays that sound through the selected output device +- Playing a sound does NOT stop other sounds (overlapping play supported) +- Optional: right-click a button → Stop this sound + +### 3. Add Audio Files +- "Add Sound" button opens a file picker dialog +- Supported formats: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aiff` +- File is registered on the board (path stored in config); it is NOT copied +- New button appears immediately in the grid +- Board layout is saved to `config.json` automatically + +### 4. Remove Audio Files +- Right-click any button → context menu with "Remove" option +- Confirmation prompt before removal +- Button is removed from the grid; entry deleted from config +- Does not delete the file from disk + +### 5. Rename Buttons +- Right-click → "Rename" option +- Simple text entry dialog +- Custom label saved to config (file path unchanged) + +### 6. Volume Control +- A global volume slider (0–100%) in the toolbar +- Scales the numpy audio array before playback +- Per-button volume is a stretch goal (not in v1) + +### 7. Stop All +- "Stop All" button immediately halts all active sounddevice streams + +### 8. Config Persistence (`config.json` schema) +```json +{ + "output_device": "CABLE Input (VB-Audio Virtual Cable)", + "volume": 80, + "buttons": [ + { + "id": "btn_001", + "label": "Air Horn", + "file_path": "C:/Users/user/sounds/airhorn.mp3" + }, + { + "id": "btn_002", + "label": "Sad Trombone", + "file_path": "C:/Users/user/sounds/sad_trombone.wav" + } + ] +} +``` + +--- + +## Key Implementation Details for Claude Code + +### Audio Playback (audio_engine.py) + +```python +import sounddevice as sd +import soundfile as sf +import numpy as np +from pydub import AudioSegment +import io + +def get_device_id(device_name: str) -> int: + """Find output device index by name substring match.""" + devices = sd.query_devices() + for i, dev in enumerate(devices): + if device_name.lower() in dev['name'].lower() and dev['max_output_channels'] > 0: + return i + raise ValueError(f"Device not found: {device_name}") + +def load_audio(file_path: str) -> tuple[np.ndarray, int]: + """Load any supported audio format, return (samples_array, samplerate).""" + ext = file_path.lower().split('.')[-1] + if ext in ('wav', 'flac', 'aiff'): + data, sr = sf.read(file_path, dtype='float32') + else: + # Use pydub for MP3/OGG/etc. + seg = AudioSegment.from_file(file_path) + seg = seg.set_channels(2).set_frame_rate(44100) + samples = np.array(seg.get_array_of_samples(), dtype=np.float32) + samples = samples.reshape((-1, 2)) / 32768.0 + data, sr = samples, 44100 + return data, sr + +def play_sound(file_path: str, device_id: int, volume: float = 1.0): + """Play audio non-blocking on the specified device.""" + data, sr = load_audio(file_path) + data = data * volume # apply volume scaling + sd.play(data, samplerate=sr, device=device_id, blocking=False) +``` + +### Threading Note +- `sd.play()` is non-blocking by default but is not thread-safe when called rapidly +- Use a `threading.Thread` per button press to avoid GUI freezes on slow file loads +- Keep a list of active streams if stop-all is needed + +### GUI Layout (main.py / board.py) +- Use `tkinter.ttk` for slightly nicer widgets +- Grid of `tk.Button` widgets built dynamically from config +- `tkinter.filedialog.askopenfilename()` for file picker +- `tkinter.simpledialog.askstring()` for rename +- `tkinter.Menu` for right-click context menus + +--- + +## VB-Cable Setup Instructions (include in README) + +1. Download and install VB-Cable from https://vb-audio.com/Cable +2. Reboot after installation +3. In the soundboard, select **"CABLE Input (VB-Audio Virtual Cable)"** as the output device +4. In Discord / OBS / Teams, set the microphone input to **"CABLE Output (VB-Audio Virtual Cable)"** +5. Audio played on the soundboard will now appear as microphone input + +--- + +## Claude Code Prompt (paste this to start the build) + +``` +Build a Python soundboard desktop app for Windows 11 using the spec in soundboard_plan.md. + +Start with this order: +1. requirements.txt +2. audio_engine.py — device listing, audio loading, play/stop functions +3. config.py — load/save JSON config +4. board.py — board state management (add, remove, rename buttons) +5. main.py — tkinter GUI that wires everything together + +Requirements: +- Output device is selectable from a dropdown at the top (auto-selects VB-Cable if found) +- Buttons play sounds non-blocking using threads so the GUI doesn't freeze +- Right-click context menu on each button: Rename, Remove +- "Add Sound" button opens a file picker (wav, mp3, ogg, flac) +- Global volume slider 0-100% +- "Stop All" button +- All state saved/loaded from config.json automatically +- No third-party GUI frameworks — tkinter only +``` + +--- + +## Stretch Goals (v2) + +- Hotkey support (bind sounds to keyboard shortcuts) +- Per-button color customization +- Drag-to-reorder buttons +- Loop toggle per button +- Waveform preview thumbnail on each button +- System tray icon so the window can be minimized