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