Soundboard design
Client-side soundboard internals, the Phase 2 yt-dlp design and the Phase 3 feature-parity 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.
Voice protocol
socketsvc.soundboard permission and soundboard-enabled switch, and server routing / trigger-rate guard.Soundboard guide
How to read this page
Client architecture and Deferred items are the as-built summary. The Phase 2 and Phase 3 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.
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
AudioSystemcall oncecom.googlecode.soundlibs:mp3spi:1.9.5.4,:vorbisspi:1.0.3.3andorg.jflac:jflac-codec:1.5.2are on the classpath, since all three register themselves as standardjavax.sound.sampledSPI 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.
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.
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.
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.
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 mirrorsIconInboxScreen'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 APIIconInboxScreenalready uses for exactly this, deliberately notjava.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 (reusingModernSlider.playerHead(...)), not a separate tab.
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.
Keybind
U (confirmed unbound by any existing default) opens/closes SoundboardScreen, registered and consumed exactly like the existing V 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 pollingisDown().
yt-dlp download from URL
- yt-dlp binary:
YtDlpNativecaches yt-dlp itself on demand throughManagedTool(SHA-256 verification of the download before it's used, executable bit set on Unix — see Phase 2 §3). 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-formatneeds ffmpeg for the actual audio extraction and does not bundle it, soFfmpegNativedoes the same on-demand download+verify throughManagedToolfor a static ffmpeg build fromyt-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:
YtDlpFetcherruns it as a subprocess to pull just the audio out of a player-pasted URL, built as an explicitProcessBuilderargument list (never a shell) with the URL as a single, final, literal argument.YtDlpFetcherTestverifies the exact argument shape, including that a deliberately shell-hostile URL never produces shell syntax. - Consent:
YtDlpDownloadConfirmScreengates the first use behind explicit consent (SocketSvcConfig.ignoreYtDlpWarningfor "don't ask again", same shape asignoreOtherVoiceChatModWarning), spelling out both trust-boundary points from Phase 2 §5. - Screen:
SoundboardScreenhosts 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.
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 and apps/updater/README.md.
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 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); it's now built (YtDlpNative, YtDlpDownloader — since folded into ManagedTool/ToolDownloader — YtDlpFetcher, YtDlpDownloadConfirmScreen, the URL bar in SoundboardScreen) — see Client architecture 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) 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.
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).
- 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.
Current code
Both of the first two out-of-scope items were later implemented separately (see Deferred items). No content search/browse UI exists; the soundboard's search box only filters local clips.
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-dlpfor Linux/mac,yt-dlp.exefor Windows, plus separateyt-dlp_macos/yt-dlp_linux_aarch64variants) — download straight tocacheDir().resolve(binaryName()), no zip/tar extraction step like the ONNX Runtime archives need. - Must be marked executable on Unix.
DeepFilterDownloadernever needs this (it downloads a shared library loaded viaSystem.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 wayONNXRUNTIME_VERSION/MODEL_BRANCHwould be bumped. - Verify the download. yt-dlp publishes SHA256SUMS per release; check the downloaded binary's hash against that before marking
.complete(DeepFilterDownloaderdoesn'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>/.
Current code
YtDlpNative.VERSIONis pinned to2026.08.19; downloads come fromhttps://github.com/yt-dlp/yt-dlp/releases/download/<VERSION>/.- Checksums are read from that release's
SHA2-256SUMSfile; the binary is written to a.partfile 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
Statusenum does includeUNSUPPORTED_PLATFORMafter all (for platforms outside that table). - The cache directory is resolved via
ClientPlatform.get().configDir()(loader-neutral) rather thanFabricLoader.getConfigDir().
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):
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.
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.
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 on OGG/FLAC) — never pass through arbitrary container formats the client can't play back.
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.
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.)
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.
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.
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.
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.
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 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.ignoreYtDlpWarningor similar, followingignoreOtherVoiceChatModWarning's existing exact pattern) — but always shown again if that flag is unset, i.e. it gates the nag, never the download itself.
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.
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 vanillaEditBoxinside aModernVoiceScreenpanel the wayVoiceSetupScreen/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'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.
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.
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.
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.
8. Testing plan
YtDlpFetcher's subprocess argument-list construction can be unit tested directly (assert the exactList<String>built for a given URL/output folder, without actually invokingProcessBuilder.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) 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/GifDecoderTestelsewhere. - 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.
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), and no dedicated test of the SHA-256 verification step was found in client/common/src/test.
9. Open questions
Original heading: "Open questions before starting implementation".
- Exact yt-dlp version to pin (§3) — 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'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'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.
Register a fixed number (e.g. 10) of ordinary KeyMappings 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 KeyMappings), for free.
Trade-off: capped at however many slots are registered, not truly one-per-clip for an unlimited library.
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) — 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.
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:
SocketSvcConfiggains a second per-clip map alongside the existingsoundClipVolumes(which becomes, semantically, "player volume" — what others hear):soundClipLocalVolumes("what I hear"). SameMap<String, Integer>/clamped-0-200 shape, samesanitize()treatment.SoundboardPlayer.runProducerneeds two independently-scaled copies of each frame per iteration (one forloopback.play, one forencoder.encode), each through its ownAudioLimiterinstance —AudioLimitercarries gain-smoothing state (gainForPeak'sgainfield) that must not be shared between two differently-scaled streams, or one channel's limiting will visibly affect the other's loudness.- UI:
SoundboardClipRowcurrently 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.soundboardMasterVolumePercentglobal 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 (playbackVolumePercentalready scales received soundboard audio inAudioPlayback.soundboardPlaybackGain) — this would add the sender-side counterpart. Implemented:soundboardMasterVolumePercentscales both sender-side channels.
Current code
The sync toggle is persisted as SocketSvcConfig.soundboardSyncVolumes (default false); soundboardMasterVolumePercent defaults to 100 and is clamped to 0–200.
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.
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.
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.
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.
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.
"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.
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.
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 onecurrentSessionfield. 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 whatAudioPlaybackalready does for receiving multiple voices, worth checking whether that mixing logic can be adapted rather than reinvented.VoiceWebSocketClient.sendSoundboard: currently keyed to one client-widesoundboardStream/soundboardSequencefield 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.
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 page.
Suggested order
Roughly cheapest/most self-contained to most invasive, though none block each other:
- (3) search
- (1) slot keybinds
- (2) local/player volume split
- pause/resume half of (4)
- (5) concurrent playback
- seek/skip/start-point/loop half of (4)