Files
soundboard-py/soundboard/README.md
T

99 lines
3.6 KiB
Markdown

# 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.