# Soundboard design

Client-side soundboard internals, the Phase 2 yt-dlp design and the Phase 3 feature-parity design

Source: https://socketsvc.vercel.app/zh-TW/client/development/soundboard-design
The soundboard lets a player trigger a local sound clip that's transmitted to nearby players as its own audio channel, independent of their microphone: muting the mic does not silence it, but a *listener's* own deafen state still blocks it, same as it blocks mic audio. It is gated server-side by a permission node (`socketsvc.soundboard`) so it can be blocked per-player/per-group regardless of what the client does.

This page covers the client side and the design history. For players, see the [Soundboard guide](/client/soundboard).

<Cards>
  <Card title="Voice protocol" href="/server/voice-protocol">Soundboard wire protocol (kind bytes, v1/v2 negotiation), the `socketsvc.soundboard` permission and `soundboard-enabled` switch, and server routing / trigger-rate guard.</Card>
  <Card title="Soundboard guide" href="/client/soundboard">Player-facing guide: clips, keybinds, volumes, search, playback and yt-dlp download.</Card>
</Cards>

<Callout type="info" title="How to read this page">
[Client architecture](#client-architecture) and [Deferred items](#deferred-items) are the as-built summary. The [Phase 2](#phase-2-yt-dlp-download) and [Phase 3](#phase-3-feature-parity) sections are the original design plans, both now **implemented**; they are kept as design rationale, not a changelog. Where the current code visibly settles an open question or differs from the plan, a "Current code" note says so — the original text is left as written.
</Callout>

## Client architecture

### Send

A triggered clip is decoded to 48kHz mono 16-bit PCM via `javax.sound.sampled.AudioSystem`:

- WAV decodes with zero extra dependencies (the JDK's built-in reader, same approach the server's `PcmSource.wav()` already uses).
- MP3, OGG and FLAC decode through the *same* `AudioSystem` call once `com.googlecode.soundlibs:mp3spi:1.9.5.4`, `:vorbisspi:1.0.3.3` and `org.jflac:jflac-codec:1.5.2` are on the classpath, since all three register themselves as standard `javax.sound.sampled` SPI providers — one decode path for all four formats, not four separate decoders.

PCM is fed through the *existing* `OpusCodec.Encoder` (same encoder the mic path already uses) in 960-sample/20ms frames, sent via a new paced sender tagged with C2S kind 1. Each concurrent session owns its stream ID and sequence counter, so interleaved clips cannot reset one another.

It never touches `AudioCapture`'s mute gate at all — mute is enforced purely by `AudioCapture` choosing not to call the mic send path, so a soundboard trigger calling its own independent send path bypasses it with no special-case code anywhere.

<Callout type="info" title="Current code">
`SoundboardAudio` also peeks at `.ogg` headers and routes Opus-in-Ogg files (which `vorbisspi` cannot decode) to a separate `OggOpusReader` decoder, so `.ogg` is not purely the single `AudioSystem` path.
</Callout>

### Receive

`VoiceWebSocketClient`/`AudioPlayback` recognize S2C kind 2 and mix it through a new, independent per-listener control — `SocketSvcConfig.soundboardPlayerVolumes` — instead of the existing `playerVolumes` used for kind 0, while still passing through the listener's own single local `deafened` check (already gates the whole incoming pipeline, both kinds).

### Library

`SoundboardManager` scans `FabricLoader.getConfigDir()/socketsvc/soundboard/` recursively; each subfolder is a category. Favorites, local/player volumes, loop flags, custom start points, and keybind-slot assignments are persisted by relative path.

<Callout type="info" title="Current code">
Since the multi-loader restructure the root is resolved loader-neutrally as `ClientPlatform.get().configDir().resolve("socketsvc").resolve("soundboard")` — still `<config dir>/socketsvc/soundboard/`. Recognized extensions are `wav`, `mp3`, `ogg` and `flac`.
</Callout>

### UI

`SoundboardScreen` is built on the existing `ModernVoiceScreen` framework, same as every other SocketSVC dialog:

- **Navigation**: breadcrumb + list navigation per folder level (a `..` row to go up, folder rows to descend). No tree widget exists anywhere in this codebase, so this mirrors `IconInboxScreen`'s flat-list-per-level style rather than inventing one.
- **Sound rows**: each row shows name, favorite, loop and keybind-slot controls, an inline local/player volume slider, and click-to-play.
- **Search** scans all subfolders.
- **Now-playing transport**: a persistent transport provides pause/resume, stop, seek, five-second skips, looping, and start-point capture.
- **Screen-level controls**: synchronized volumes, sender-side master volume, and opt-in concurrent playback.
- **Open folder**: a button uses `Util.getPlatform().openPath(...)` — the same API `IconInboxScreen` already uses for exactly this, deliberately not `java.awt.Desktop` (documented AWT/LWJGL windowing conflict on macOS elsewhere in this codebase).
- **Per-listener volume**: the per-listener soundboard volume/mute control is a row directly under each player's existing mic-volume row in `VoiceSettingsScreen`'s Players tab (reusing `ModernSlider.playerHead(...)`), not a separate tab.

<Callout type="info" title="Current code">
The open-folder button (and `IconInboxScreen`) now call `DesktopFolder.open(...)`, which runs `explorer.exe` / `open` / `xdg-open` directly. Minecraft removed `Util.getPlatform().openPath` in 26.3's platform-abstraction rework, so the mod does this itself on every supported version — still never `java.awt.Desktop`.
</Callout>

### Keybind

<kbd>U</kbd> (confirmed unbound by any existing default) opens/closes `SoundboardScreen`, registered and consumed exactly like the existing <kbd>V</kbd> settings key.

### Quick-pick keybind

A second, default-*unbound* keybinding (`key.socketsvc.soundboard_quickpick`) opens `SoundboardQuickPickScreen` on press — a small overlay strip above the hotbar listing favorited clips (`SoundboardManager.listFavorites()`).

- Scrolling moves the highlight.
- Releasing the physical key (polled every tick via `SocketSvcClient.isSoundboardQuickPickKeyDown()`, the same "`isDown()` works regardless of open screen" reliance already used for push-to-talk) plays whichever clip is highlighted and closes the overlay.
- Escape or a left click cancel/confirm without needing a `KeyMapping` "hold" primitive beyond polling `isDown()`.

### yt-dlp download from URL

- **yt-dlp binary**: `YtDlpNative` caches yt-dlp itself on demand through `ManagedTool` (SHA-256 verification of the download before it's used, executable bit set on Unix — see [Phase 2 §3](#3-downloading-the-yt-dlp-binary)). Which version is decided by the update service's `/v1/tools/yt-dlp`, not pinned in code; see *Current code* below.
- **ffmpeg**: yt-dlp's own `-x`/`--audio-format` needs ffmpeg for the actual audio extraction and does not bundle it, so `FfmpegNative` does the same on-demand download+verify through `ManagedTool` for a static ffmpeg build from `yt-dlp/FFmpeg-Builds` (Windows/Linux only — that upstream project publishes no macOS builds). A system ffmpeg already on PATH is always preferred and used as-is on any platform, including macOS via e.g. `brew install ffmpeg`. Without this, every "Download from URL" attempt on a machine without a pre-installed ffmpeg would fail outright with an opaque yt-dlp error.
- **Fetch**: `YtDlpFetcher` runs it as a subprocess to pull just the audio out of a player-pasted URL, built as an explicit `ProcessBuilder` argument list (never a shell) with the URL as a single, final, literal argument. `YtDlpFetcherTest` verifies the exact argument shape, including that a deliberately shell-hostile URL never produces shell syntax.
- **Consent**: `YtDlpDownloadConfirmScreen` gates the *first* use behind explicit consent (`SocketSvcConfig.ignoreYtDlpWarning` for "don't ask again", same shape as `ignoreOtherVoiceChatModWarning`), spelling out both trust-boundary points from [Phase 2 §5](#5-consent-screen).
- **Screen**: `SoundboardScreen` hosts the URL field + Download button (replaced by a status line + Cancel while a fetch is running); a successful download lands in whichever folder is currently open and the listing refreshes automatically.

<Callout type="info" title="Current code">
`FfmpegNative` also probes common install locations beyond plain PATH lookup (`/opt/homebrew/bin/ffmpeg`, `/usr/local/bin/ffmpeg`, `/usr/bin/ffmpeg`, `/snap/bin/ffmpeg`), because GUI-launched Minecraft processes often inherit a stripped-down PATH; a system ffmpeg found only there is passed to yt-dlp via `--ffmpeg-location`. Both tools now go through one `ManagedTool` / `ToolDownloader` path (the separate `YtDlpDownloader` and `FfmpegDownloader` are gone). The update service picks the version per platform from each tool's upstream releases — dated yt-dlp tags, FFmpeg-Builds' `autobuild-*` tags rather than the moving `latest` — and hands out a versioned URL with the release's own SHA-256, which fixed the race where a `latest` rebuild made a good archive fail its checksum. Each version installs into its own `v-<version>` folder with an `installed.json` pointer, so an update never overwrites a running binary; installed tools follow the served version on launch (`autoUpdateTools`), and a failed yt-dlp run triggers an immediate check. With the service unreachable, a first download falls back to a version compiled into `YtDlpNative`/`FfmpegNative`. See [Updates → Tools](/client/updates#tools) and `apps/updater/README.md`.
</Callout>

## Deferred items

Nothing — every item originally listed as deferred (yt-dlp auto-download, FLAC, the hold-and-scroll quick-pick keybind) is now implemented. The yt-dlp piece's original design rationale is in [Phase 2](#phase-2-yt-dlp-download) below.

## Phase 2: yt-dlp download

**Status: implemented.** This started as a forward-looking plan for the piece deferred out of Phase 1 (see [Deferred items](#deferred-items)); it's now built (`YtDlpNative`, `YtDlpDownloader` — since folded into `ManagedTool`/`ToolDownloader` — `YtDlpFetcher`, `YtDlpDownloadConfirmScreen`, the URL bar in `SoundboardScreen`) — see [Client architecture](#yt-dlp-download-from-url) for the as-built summary. The sections below are kept as-is (design rationale, not a changelog); where implementation diverged from the original plan in a small way, it's noted inline.

### 1. Why this is its own phase

Every other piece of this mod that fetches something on demand (`DeepFilterDownloader`/`DeepFilterNative`) downloads a **fixed, versioned, first-party asset** (ONNX Runtime from Microsoft's own GitHub releases; a named DeepFilterNet model) to a **known URL baked into the mod**. yt-dlp is different in two ways that change the trust/consent story:

- It **downloads and executes a separate third-party binary** (GPL-3.0 licensed, not ours) as a subprocess, not a passive data file.
- That binary then **fetches arbitrary content from a URL the player supplies**, from whatever site they paste — not a single fixed, audited upstream.

Both of those are meaningfully bigger trust boundaries than anything this mod does today, so this needs its own explicit, worse-case-labeled consent screen (see [§5](#5-consent-screen)) rather than reusing `DeepFilterDownloadConfirmScreen`'s wording as-is, even though the *mechanics* below intentionally mirror that existing download machinery closely.

### 2. Scope

**In scope:**

- Download the yt-dlp binary itself, on demand, per platform (mirrors `DeepFilterNative`'s on-demand-download shape).
- A "Download from URL" entry point in `SoundboardScreen` (paste a URL, hit download) that runs yt-dlp as a subprocess to fetch just the audio into the soundboard folder in a supported format.
- Progress, cancel, and error reporting for that subprocess.
- A one-time (dismissible-forever, like `OtherVoiceChatModWarning`'s "don't warn me again") consent screen before the *first* download, spelling out both trust-boundary points from [§1](#1-why-this-is-its-own-phase).

**Out of scope (unchanged from the Phase 1 deferred list, still deferred beyond this phase too):**

- The hold-and-scroll wheel quick-play keybind.
- FLAC playback support (yt-dlp *can* output FLAC; whether it's offered as a format choice here depends on that separately-tracked decision, not on this plan — see [§7](#7-format-decision)).
- Any kind of built-in search/browse UI for content — this is strictly "paste a URL you already have," the same scope as running yt-dlp yourself from a terminal, never a discovery feature.

<Callout type="info" title="Current code">
Both of the first two out-of-scope items were later implemented separately (see [Deferred items](#deferred-items)). No content search/browse UI exists; the soundboard's search box only filters local clips.
</Callout>

### 3. Downloading the yt-dlp binary

Mirrors `DeepFilterNative`/`DeepFilterDownloader`'s shape closely: a `YtDlpNative`-equivalent status/cache class, a downloader that uses `java.net.http.HttpClient` (already the only HTTP mechanism used anywhere in this codebase — no new dependency), a `Status` enum (`NOT_DOWNLOADED`/`DOWNLOADING`/`READY`/`FAILED`; no `UNSUPPORTED_PLATFORM` needed since yt-dlp publishes binaries for every platform this mod supports), and a `.complete` marker file gating "ready," same as `DeepFilterNative.cacheDir()`.

Differences from the DeepFilterNet case:

- **One binary, not an archive to extract.** yt-dlp's GitHub releases publish a single executable per platform (`yt-dlp` for Linux/mac, `yt-dlp.exe` for Windows, plus separate `yt-dlp_macos`/`yt-dlp_linux_aarch64` variants) — download straight to `cacheDir().resolve(binaryName())`, no zip/tar extraction step like the ONNX Runtime archives need.
- **Must be marked executable on Unix.** `DeepFilterDownloader` never needs this (it downloads a shared library loaded via `System.load`, not run as a process) — after download, `file.toFile().setExecutable(true)` on mac/Linux (a no-op, harmless, on Windows).
- **Pin an exact version, same principle as `ONNXRUNTIME_VERSION`.** Track a specific yt-dlp release tag, not "latest," so behavior doesn't shift under players without a mod update; bump it deliberately in a future commit the same way `ONNXRUNTIME_VERSION`/`MODEL_BRANCH` would be bumped.
- **Verify the download.** yt-dlp publishes SHA256SUMS per release; check the downloaded binary's hash against that before marking `.complete` (`DeepFilterDownloader` doesn't currently do this for the ONNX assets — worth doing here regardless, since this asset is an *executable*, not a passive library).

Cache location: `FabricLoader.getConfigDir()/socketsvc/ytdlp/<platform>/`, same parent (`socketsvc/`) and per-platform-subdirectory convention as `socketsvc/deepfilter/<platform>/`.

<Callout type="info" title="Current code">
- `YtDlpNative.VERSION` is pinned to `2026.08.19`; downloads come from `https://github.com/yt-dlp/yt-dlp/releases/download/<VERSION>/`.
- Checksums are read from that release's `SHA2-256SUMS` file; the binary is written to a `.part` file first, then marked executable and `.complete`.
- Asset names per platform: `windows-x64` → `yt-dlp.exe`, `windows-aarch64` → `yt-dlp_arm64.exe`, `mac-x64`/`mac-aarch64` → `yt-dlp_macos` (universal2), `linux-x64` → `yt-dlp_linux`, `linux-aarch64` → `yt-dlp_linux_aarch64`.
- The `Status` enum does include `UNSUPPORTED_PLATFORM` after all (for platforms outside that table).
- The cache directory is resolved via `ClientPlatform.get().configDir()` (loader-neutral) rather than `FabricLoader.getConfigDir()`.
</Callout>

### 4. Downloading a clip

New class, e.g. `YtDlpFetcher`, invoked from a background thread (never the render thread, same rule as everywhere else audio/file work happens in this codebase):

<Steps>
<Step>

**Validate the URL.** Check the pasted URL is `http://`/`https://` before doing anything else — reject obvious garbage input early with a clear error rather than handing it to the subprocess.

</Step>
<Step>

**Build an argument list, never a shell.** Build the subprocess via `ProcessBuilder` with an explicit argument **list** (`["<binary>", "-x", "--audio-format", "mp3", "-o", "<template>", "--no-playlist", "<url>"]`) — **never** through a shell (`ProcessBuilder("sh", "-c", ...)` or similar), so the URL can never be interpreted as shell syntax regardless of what a player pastes. This is the same command-injection concern called out generally in this project's own security guidance, directly applicable here since the input is fully player-controlled.

</Step>
<Step>

**Fixed audio format.** `-x`/`--extract-audio` with `--audio-format` fixed to whichever format(s) Phase 1 already decodes (`mp3`, matching the existing `mp3spi` dependency — see [§7](#7-format-decision) on OGG/FLAC) — never pass through arbitrary container formats the client can't play back.

</Step>
<Step>

**No playlists.** `--no-playlist` always — one URL should never silently expand into downloading dozens of files; a player who wants a playlist can paste each link themselves.

</Step>
<Step>

**Output into the open folder.** The output template writes into the **active folder the player has open** in `SoundboardScreen` (so a download lands exactly where they were browsing, same category/subfolder), named from yt-dlp's own `%(title)s`.

*(As built: no separate filename-sanitization step was added on our side — yt-dlp's own `-o` template handling already sanitizes a title's path separators before it becomes part of a filename, which is the actual injection surface here, and the output directory itself is always our own fixed, already-validated folder rather than anything derived from the URL or title. `YtDlpFetcher.fetch` still verifies the file it reports having written is actually inside that folder before returning it, as a cheap extra backstop against a parsing edge case or an unexpected yt-dlp behavior change.)*

</Step>
<Step>

**Progress.** Parse yt-dlp's stdout progress lines (`[download]  42.0% of ...`) for a progress percentage, same `downloadedBytes`/`totalBytes`-style volatiles `DeepFilterNative` already exposes, so the UI can show a progress bar the same way `SocketSvcConfigScreen`'s Advanced Noise Cancellation row already does for its download.

</Step>
<Step>

**Exit handling.** On process exit: non-zero exit code → surface stderr's last line as the error (mirrors how `SoundboardPlayer`'s decode failures already surface a message via `onError`); zero → refresh `SoundboardScreen`'s current folder listing (bump its existing `refreshTick`, same mechanism the manual "Refresh" button already uses) so the new file appears without the player needing to reopen the screen.

</Step>
<Step>

**Cancelable.** Must be cancelable mid-download: keep the running `Process` handle, `Process.destroy()` on cancel, same "one in flight, a new trigger/cancel stops the previous one" shape `SoundboardPlayer` already uses for playback.

</Step>
</Steps>

<Callout type="info" title="Current code">
`YtDlpFetcher.buildCommand` produces: binary, `-x`, `--audio-format`, `mp3`, `--no-playlist`, `--newline`, optionally `--ffmpeg-location <path>`, then `-o <destFolder>/%(title)s.%(ext)s`, and the URL as the final element. stderr is merged into stdout (`redirectErrorStream(true)`), so the error surfaced on a non-zero exit is the last output line. The output file path is taken from yt-dlp's `Destination: ` line.
</Callout>

### 5. Consent screen

New `YtDlpDownloadConfirmScreen` (structurally identical to `DeepFilterDownloadConfirmScreen` — wrapped body text via `font.split`, Cancel/Download buttons at the bottom of the panel), but its own distinct copy covering *both* trust-boundary points from [§1](#1-why-this-is-its-own-phase) explicitly:

- Downloads and runs a third-party program (yt-dlp, GPL-3.0) to fetch audio from the URL you provide.
- SocketSVC has no control over, and does not vet, whatever site or content a pasted URL points to — only download from sources you trust, and only content you have the right to use.
- Shown once, with a persistent "don't ask me again" option (`SocketSvcConfig.ignoreYtDlpWarning` or similar, following `ignoreOtherVoiceChatModWarning`'s existing exact pattern) — but *always* shown again if that flag is unset, i.e. it gates the nag, never the download itself.

<Callout type="warn">
This is a legal/trust surface, not just a UX nicety — do not let "download from URL" fire without this screen having been accepted at least once.
</Callout>

### 6. SoundboardScreen integration

- A new row/button in `SoundboardScreen`, visible in every folder view (not just root) so a download always lands in whatever folder is currently open: opens a small text-input prompt for the URL (no existing text-input widget in this codebase's Modern* widget set — needs a new one, or reuse Minecraft's vanilla `EditBox` inside a `ModernVoiceScreen` panel the way `VoiceSetupScreen`/other screens already mix vanilla widgets with the Modern* framework where needed).
- While a download is running, show progress inline (a slim progress bar row, or repurpose `ModernMeter` — already used for mic level display — for a determinate 0-100% bar) plus a Cancel action.
- Route the "first ever download" check through [§5](#5-consent-screen)'s confirm screen before actually calling into `YtDlpFetcher`; subsequent downloads in the same session (or ever, if "don't ask again" was set) skip straight to downloading.

<Callout type="info" title="Current code">
The URL field is a vanilla `EditBox`, and progress is shown as a percentage in a status line rather than a bar. The status line distinguishes downloading the yt-dlp binary, downloading ffmpeg, and fetching the clip. In the current code the confirm screen appears whenever `ignoreYtDlpWarning` is unset (i.e. before each download until "Don't ask again" is chosen), not only on the first download of a session. After confirmation, missing yt-dlp and ffmpeg binaries are downloaded automatically before the fetch starts.
</Callout>

### 7. Format decision

*Original heading: "Format decision (needs revisiting before implementation)".*

Phase 1 decodes WAV/MP3/OGG (`SoundboardAudio`, via the JDK's own WAV reader plus the bundled `mp3spi`/`vorbisspi`). yt-dlp itself can produce many formats via `--audio-format` (`best`, `mp3`, `m4a`, `opus`, `vorbis`, `wav`, `flac`, ...), but this feature must never let a player produce a file `SoundboardAudio` can't actually play back — so `--audio-format` here should be **fixed to `mp3`** unless/until FLAC (or another format) is separately implemented client-side per the Phase 1 FLAC deferral.

Extracting straight to `opus` and skip re-encoding would be the most efficient option quality/size-wise (yt-dlp's source is very often already Opus-encoded) — worth a follow-up look at whether `javax.sound.sampled` can be made to decode raw Opus-in-ogg via the existing `vorbisspi` dependency, or whether that needs its own separate decode path; not resolved in this plan and should be settled before writing `YtDlpFetcher`.

<Callout type="info" title="Resolved in code">
`--audio-format` is fixed to `mp3`. FLAC decoding was later added (`jflac-codec`), and Opus-in-Ogg got its own separate decode path (`OggOpusReader`), because `vorbisspi` only handles Vorbis. yt-dlp output was nonetheless left at `mp3`; extracting to `opus` directly has not been adopted.
</Callout>

### 8. Testing plan

- `YtDlpFetcher`'s subprocess argument-list construction can be unit tested directly (assert the exact `List<String>` built for a given URL/output folder, without actually invoking `ProcessBuilder.start()`) — the same "test the command shape, not a real subprocess" approach worth using given this repo's tests don't spin up real external processes anywhere today.
- Filename sanitization (yt-dlp's `%(title)s` → a safe on-disk name) gets its own focused test table (path separators, `..`, leading dots, control characters, empty string).
- The download-binary verification step ([§3](#3-downloading-the-yt-dlp-binary)) gets a test against a known good/bad SHA256 pair using a small synthetic fixture, same "build a real fixture, verify against it" discipline as `SoundboardAudioTest`/`GifDecoderTest` elsewhere.
- Manual/real-network verification (can't run in this environment): an actual URL download end-to-end, cancel mid-download, a deliberately invalid URL, a very long video (progress bar sanity), running on all three OS families for the executable-bit/binary-selection logic.

<Callout type="info" title="Current code">
`YtDlpFetcherTest` covers the fixed argument list with the URL as its own final literal element, shell-hostile URLs, the output template anchored in the destination folder, `--ffmpeg-location` present/absent, and cancel as a no-op when idle. There is no filename-sanitization test table (consistent with the as-built note in [§4](#4-downloading-a-clip)), and no dedicated test of the SHA-256 verification step was found in `client/common/src/test`.
</Callout>

### 9. Open questions

*Original heading: "Open questions before starting implementation".*

- Exact yt-dlp version to pin ([§3](#3-downloading-the-yt-dlp-binary)) — needs a real decision at implementation time the same way FastStats/Velocity/BungeeCord versions were verified against real Maven metadata earlier in this project's history, not guessed.
- Whether to bundle a `yt-dlp.d/config.txt`-style fixed argument set (rate limiting, no-playlist, format) that a player cannot override, vs. any player-facing "advanced options" surface at all — current lean is **no player-facing raw argument passthrough**, ever, given [§4](#4-downloading-a-clip)'s command-injection framing; only ever the fixed, hardcoded argument list.
- Whether output should default to the folder currently open in `SoundboardScreen` (§4.5) or always to soundboard root with the player free to move it after — current lean is "currently open folder," since it matches how the player was already browsing/organizing when they hit download.

| Open question | Resolved in code |
| --- | --- |
| yt-dlp version pin | `YtDlpNative.VERSION = "2026.08.19"` |
| Fixed arguments vs. player options | Fixed, hardcoded argument list built in `YtDlpFetcher.buildCommand`; no config file and no player-facing passthrough |
| Output folder | The folder currently open in `SoundboardScreen` |

## Phase 3: feature parity

**Status: implemented.** This covers the five gaps identified by comparing our soundboard against [XCraftTM/OpenSoundboard](https://github.com/XCraftTM/OpenSoundboard)'s README feature list, after the one real bug that comparison surfaced (yt-dlp needing ffmpeg, which we didn't download or check for) was already fixed directly. Each section below is independently implementable; none of them block each other, though §4 and §5 touch some of the same files.

### 1. Per-sound custom keybinds

**Implemented:** ten default-unbound slots, persisted clip assignments, row-level slot cycling, and tick polling through the normal Minecraft keybinding system.

**Problem**: Minecraft's keybinding system (`KeyMapping`/`KeyBindingHelper`) requires every bindable key to be registered once, statically, at mod init — the vanilla Controls screen and the game's key-conflict-context system are both built around a fixed, known-at-startup set of `KeyMapping` objects. There is no supported way to register a new one dynamically the moment a player drops a new sound file into their folder.

<Accordions>
<Accordion title="(a) A fixed pool of generic slot keybinds — recommended">

Register a fixed number (e.g. 10) of ordinary `KeyMapping`s at init — `key.socketsvc.soundboard_slot_1` .. `_10`, all default-unbound, same registration shape as every other keybind in `SocketSvcClient.java`. `SocketSvcConfig` gains `Map<Integer, String> soundboardSlotClips` (slot number -> clip relative path). `SoundboardScreen` gets a "bind to slot" action per clip row (a small numbered button, or a dropdown/cycle picker) that writes that map entry. The existing `while (slotKey.consumeClick())` polling pattern already used for every other key triggers `VoiceSession.playSoundboardClip(config.soundboardSlotClips.get(slot))` when a slot's clip is set. Rebinding still goes through vanilla's own Controls screen (these are real `KeyMapping`s), for free.

**Trade-off**: capped at however many slots are registered, not truly one-per-clip for an unlimited library.

</Accordion>
<Accordion title="(b) True per-clip raw key capture — not recommended">

A "bind key" button next to a clip that listens for the next physical keypress and stores that raw key, checked via a low-level input hook (a Mixin into keyboard/mouse dispatch) instead of the vanilla `KeyMapping` system, so it isn't capped at a fixed slot count and doesn't need pre-registration.

Rejected because it needs exactly the kind of raw-input Mixin this project deliberately avoided for the wheel quick-pick keybind (see [Quick-pick keybind](#quick-pick-keybind)) — a Mixin into low-level input dispatch is more version-fragile than the pure-`KeyMapping` approach and needs re-verifying against every supported Minecraft version's actual method signatures, the same risk that pushed the wheel feature toward a Screen-overlay design instead.

</Accordion>
</Accordions>

**Plan**: implement (a). New `key.socketsvc.soundboard_slot_<n>` bindings (pick a count — 10 mirrors a hotbar and is a reasonable start, easy to raise later since it's just more static registrations), `SocketSvcConfig.soundboardSlotClips`, slot-assignment UI in `SoundboardClipRow`/`SoundboardScreen`, polling in `SocketSvcClient`'s tick handler.

### 2. Local vs. player volume per clip

*Original heading: "Independent local vs. player volume per clip".*

**Implemented:** the compact row control switches between `L` (local) and `P` (player/network) volume. Playback uses separate PCM copies and limiter state for the two paths.

**Current state** (at planning time): `SoundboardPlayer.runProducer` applies exactly one volume (`SocketSvcConfig.soundClipVolume`) to each PCM frame *before* both `loopback.play(copy)` (what the triggering player hears) and `encoder.encode(copy)` (what gets sent to everyone else) — one shared value drives both.

**Plan**:

- `SocketSvcConfig` gains a second per-clip map alongside the existing `soundClipVolumes` (which becomes, semantically, "player volume" — what others hear): `soundClipLocalVolumes` ("what I hear"). Same `Map<String, Integer>`/clamped-0-200 shape, same `sanitize()` treatment.
- `SoundboardPlayer.runProducer` needs two independently-scaled copies of each frame per iteration (one for `loopback.play`, one for `encoder.encode`), each through its *own* `AudioLimiter` instance — `AudioLimiter` carries gain-smoothing state (`gainForPeak`'s `gain` field) that must not be shared between two differently-scaled streams, or one channel's limiting will visibly affect the other's loudness.
- UI: `SoundboardClipRow` currently has one volume track per clip; adding a second value without doubling every row's height needs a compact toggle (e.g. a small "L"/"P" label button before the track) that switches which of the two volumes the single visible slider/track currently shows and edits — keeps the list as dense as it is today instead of doubling its height. A "synced" mode (OpenSoundboard's own optional simplification, one slider drives both values together) is a reasonable secondary addition once the split itself exists, not a precondition for it. **Implemented:** the screen-level sync toggle makes edits to either channel update both persisted values.
- Optional, lower priority: a `config.soundboardMasterVolumePercent` global multiplier on top of every per-clip local/player value, mirroring OpenSoundboard's "global volume multiplier for both channels." Partial equivalent already exists listener-side today (`playbackVolumePercent` already scales received soundboard audio in `AudioPlayback.soundboardPlaybackGain`) — this would add the *sender*-side counterpart. **Implemented:** `soundboardMasterVolumePercent` scales both sender-side channels.

<Callout type="info" title="Current code">
The sync toggle is persisted as `SocketSvcConfig.soundboardSyncVolumes` (default `false`); `soundboardMasterVolumePercent` defaults to `100` and is clamped to 0–200.
</Callout>

### 3. Search/filter box

**Implemented:** the persistent search field performs case-insensitive name matching over the flat recursive clip list and reports an explicit no-results state.

**Plan**: the smallest of the five. Add an `EditBox` to `SoundboardScreen` (same construction/re-add-each-rebuild pattern the yt-dlp URL field already established), filtering by case-insensitive substring match on clip name. A non-empty query switches the list to a flat, recursive view (reusing the already-implemented `SoundboardManager.listAllClipsRecursively()`, same as the Favorites view already does) instead of the current single-folder listing, since a search should find a clip regardless of which subfolder it's actually filed under.

### 4. Playback controls

*Original heading: "Playback controls: pause/resume, seek, skip, custom start point, loop".*

**Implemented:** the now-playing transport exposes pause/resume, stop, five-second skips, a seek slider once duration scanning completes, per-clip start-point capture, and per-clip looping. Durations are cached by relative path and file modification time.

The largest and most architecturally invasive of the five — real rework of the streaming decode/send pipeline, not additive UI.

<Accordions>
<Accordion title="Pause/resume">

The *cheapest* of this group, actually. The producer/consumer split already built for the lag fix (`SoundboardPlayer` javadoc) makes this nearly free: a `paused` `AtomicBoolean` the consumer checks each loop iteration — while paused, the consumer simply stops pulling frames (and stops sending/looping-back); the producer keeps decoding into the bounded queue until it fills, then naturally blocks on `queue.put()` (existing backpressure, no new logic needed there). Resuming just clears the flag and the consumer picks up exactly where it left off.

</Accordion>
<Accordion title="Seek/skip">

Much harder. `javax.sound.sampled`'s decode stream for a compressed source has no true random-access seek — `AudioInputStream.skip()` only works by continuing to decode-and-discard, i.e. it's O(distance skipped), not O(1).

Pragmatic approach: stop the current producer/consumer session and start a *fresh* decode of the same file, immediately calling `skip()` up to the target byte offset before entering the normal frame loop — correct, but costs real decode time proportional to the seek target (acceptable for clip-length audio, seconds not hours). "Skip forward/back" is the same mechanism with a relative rather than absolute target.

</Accordion>
<Accordion title="Clip duration">

Needed for a seek bar/timeline at all: not reliably known upfront for a compressed source without either trusting (sometimes-approximate) container metadata or doing a full decode pass.

Plan: measure lazily on first play (decode-and-discard once, same cost as playing it once) and cache the result keyed by relative path + file mtime (invalidated automatically if the file changes), so repeat plays/seeks of the same clip don't re-scan every time.

</Accordion>
<Accordion title="Custom start point">

A new `SocketSvcConfig.soundClipStartMs` per-clip map; applied as an automatic initial `skip()` (same mechanism as manual seeking above) whenever that clip is triggered normally, not just when the player manually scrubs.

</Accordion>
<Accordion title="Loop">

"Loop this one sound" is comparatively simple — when the producer reaches EOF, if a per-clip loop flag is set, restart decoding the same file from the top instead of ending the session.

"Loop all sounds" implies an actual *playlist/queue* concept (auto-advance through a list) that doesn't exist in this mod's model at all today (only ever one triggered clip, ever) — meaningfully bigger than per-clip looping, and not clearly requested independent of it; recommend scoping it separately/later rather than bundling it into this pass.

</Accordion>
<Accordion title="Transport UI">

Needs a new persistent "now playing" transport bar (playhead position, play/pause/stop, seek slider, skip buttons) that appears only while something is playing — not just the existing per-row star/stop, since transport controls apply to *the* currently-playing clip, not each row independently. Closest existing precedent in this codebase is the yt-dlp status bar in `SoundboardScreen` (appears only while a download is active) — similar shape, different content.

</Accordion>
</Accordions>

### 5. Concurrent playback (toggle)

**Implemented:** concurrency is opt-in and negotiated-soundboard-only. Sessions are keyed by clip, use independent stream/sequence identities, and feed a clocked local PCM mixer. The legacy mic fallback retains single-clip playback because its protocol cannot represent parallel streams.

Decided: add as an opt-in toggle (`SocketSvcConfig.soundboardAllowConcurrent`, default off — today's stop-previous behavior stays the default). More invasive than "just a toggle" once traced through every layer that currently assumes exactly one active soundboard voice:

- **`SoundboardPlayer`**: currently tracks one `current` `Session` field. Needs to become a collection (e.g. `Map<String relativePath, Session>`, keyed by clip so re-triggering the *same* clip still restarts it rather than stacking duplicates, while *different* clips can run simultaneously when the toggle is on). With the toggle off, `play()` clears the whole map before adding the one new entry — same effective behavior as today.
- **`SoundboardLoopback`**: today it's a simple pass-through PCM writer to one output line. Multiple concurrent producer threads writing to that same line directly would *interleave* rather than *mix* — audibly garbled, not overlapping — the same class of bug the mic+soundboard rate-limit issue taught us to watch for when two independent streams share one resource. Needs to become a real small mixer (sum concurrent PCM streams with limiting/clipping protection) — conceptually close to what `AudioPlayback` already does for receiving multiple voices, worth checking whether that mixing logic can be adapted rather than reinvented.
- **`VoiceWebSocketClient.sendSoundboard`**: currently keyed to one client-wide `soundboardStream`/`soundboardSequence` field pair. Two concurrent clips sharing that single stream identity would corrupt the receiving side's per-stream sequencing exactly like two frame sources sharing one queue slot did earlier in this feature's history (the "same route-tracking slot" note in the original soundboard design doc). Needs a fresh, independent stream/sequence pair generated per concurrent playback session instead of one shared field.

<Callout type="info" title="Current code">
`SoundboardLoopback` is now a "real-time mixer for all locally playing soundboard sessions" with its own `AudioLimiter`. Turning the concurrent toggle off in `SoundboardScreen` immediately enforces single playback. The server-side ten-stream limit for Soundboard v2 is described on the [Voice protocol](/server/voice-protocol) page.
</Callout>

### Suggested order

Roughly cheapest/most self-contained to most invasive, though none block each other:

1. **(3) search**
2. **(1) slot keybinds**
3. **(2) local/player volume split**
4. pause/resume half of **(4)**
5. **(5) concurrent playback**
6. seek/skip/start-point/loop half of **(4)**