99 lines
3.6 KiB
Markdown
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.
|