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