round from from Claude Opus 4.8

This commit is contained in:
2026-05-30 15:11:07 -06:00
parent 981106d3df
commit d71fcbab25
12 changed files with 1294 additions and 0 deletions
+23
View File
@@ -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"
}
}
}
+98
View File
@@ -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.
+254
View File
@@ -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)
+105
View File
@@ -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())
+72
View File
@@ -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)
+283
View File
@@ -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("<<ComboboxSelected>>", 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("<ButtonRelease-1>", 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(
"<Configure>", 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("<Button-3>", 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()
+12
View File
@@ -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
View File
+72
View File
@@ -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()
+100
View File
@@ -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()
+66
View File
@@ -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()
+209
View File
@@ -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 (0100%) 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