round from from Claude Opus 4.8
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user