diff --git a/.github/macports.yml b/.github/macports.yml index de075020de3..9d1c494246a 100644 --- a/.github/macports.yml +++ b/.github/macports.yml @@ -20,4 +20,8 @@ ports: - name: opusfile select: [ universal ] - name: libvorbis - select: [ universal ] \ No newline at end of file + select: [ universal ] + # FluidSynth is built from source in soh/CMakeLists.txt (osal=cpp11, no glib). + # libsndfile is a dependency of FluidSynth, needed to decode SF3 SoundFonts. + - name: libsndfile + select: [ universal ] diff --git a/CMakeLists.txt b/CMakeLists.txt index d92b7379b23..7b039c74359 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,7 +101,8 @@ if (CMAKE_SYSTEM_NAME STREQUAL "Windows") endif() vcpkg_bootstrap() - vcpkg_install_packages(zlib bzip2 libzip libpng sdl2 sdl2-net glew glfw3 nlohmann-json tinyxml2 spdlog libogg libvorbis opus opusfile) + # fluidsynth[sndfile] pulls libsndfile with Ogg/Vorbis, required to decode SF3 SoundFonts. + vcpkg_install_packages(zlib bzip2 libzip libpng sdl2 sdl2-net glew glfw3 nlohmann-json tinyxml2 spdlog libogg libvorbis opus opusfile fluidsynth[sndfile]) if (CMAKE_C_COMPILER_LAUNCHER MATCHES "ccache|sccache") set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT Embedded) endif() @@ -193,6 +194,11 @@ add_compile_definitions(CONTROLLERBUTTONS_T=uint32_t) ################################################################################ # Sub-projects ################################################################################ +# Synth code lives SoH-side (soh/soh/Enhancements/audio), so soh links FluidSynth. +# Default ON; override with -DENABLE_FLUIDSYNTH=OFF. +if(NOT DEFINED ENABLE_FLUIDSYNTH) + set(ENABLE_FLUIDSYNTH ON) +endif() add_subdirectory(libultraship ${CMAKE_BINARY_DIR}/libultraship) target_compile_options(libultraship PRIVATE "${WARNING_OVERRIDE}") target_compile_definitions(libultraship PUBLIC INCLUDE_MPQ_SUPPORT) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 4a6ba5faec6..832be4083b1 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -164,6 +164,7 @@ git submodule update --init # Add `-DCMAKE_BUILD_TYPE:STRING=Release` if you're packaging # Add `-DSUPPRESS_WARNINGS=0` to prevent suppression of warnings from LUS and decomp (src) files. set to 1 to re-enable suppression # Add `-DPython3_EXECUTABLE=$(which python3)` if you are using non-standard Python installations such as PyEnv +# Add `-DENABLE_FLUIDSYNTH=OFF` to build without the FluidSynth synth backend (on by default; drops the fluidsynth dependency) cmake -H. -Bbuild-cmake -GNinja # Generate soh.o2r @@ -242,11 +243,12 @@ cd ShipWright git submodule update --init # Install development dependencies (assuming homebrew) -brew install sdl2 sdl2_net libpng glew ninja cmake tinyxml2 nlohmann-json libzip opusfile libvorbis +brew install sdl2 sdl2_net libpng glew ninja cmake tinyxml2 nlohmann-json libzip opusfile libvorbis fluid-synth # Generate Ninja project # Add `-DCMAKE_BUILD_TYPE:STRING=Release` if you're packaging # Add `-DSUPPRESS_WARNINGS=0` to prevent suppression of warnings from LUS and decomp (src) files. set to 1 to re-enable suppression +# Add `-DENABLE_FLUIDSYNTH=OFF` to build without the FluidSynth synth backend (on by default; drops the fluidsynth dependency) cmake -H. -Bbuild-cmake -GNinja # Generate soh.o2r @@ -296,7 +298,7 @@ cmake -H. -Bbuild-cmake -GNinja # Extract assets & generate OTR (run this anytime you need to regenerate OTR) cmake --build build-cmake --target ExtractAssets # Setup cmake project for building for Switch -cmake -H. -Bbuild-switch -GNinja -DCMAKE_TOOLCHAIN_FILE=/opt/devkitpro/cmake/Switch.cmake +cmake -H. -Bbuild-switch -GNinja -DCMAKE_TOOLCHAIN_FILE=/opt/devkitpro/cmake/Switch.cmake -DENABLE_FLUIDSYNTH=OFF # Build project and generate nro cmake --build build-switch --target soh_nro @@ -304,6 +306,8 @@ cmake --build build-switch --target soh_nro # To develop the project open the repository in VSCode (or your preferred editor) ``` +_Note: FluidSynth is disabled above because devkitpro doesn't ship it. To use the synth backend, build a fluidsynth for the platform (e.g. [fluidsynth-lite](https://github.com/rsn8887/fluidsynth-lite)) and drop `-DENABLE_FLUIDSYNTH=OFF`._ + ## Wii U 1. Requires that your build machine is setup with the tools necessary for your platform above 2. Requires that you have the Wii U build tools installed @@ -317,7 +321,7 @@ cmake -H. -Bbuild-cmake -GNinja # Extract assets & generate OTR (run this anytime you need to regenerate OTR) cmake --build build-cmake --target ExtractAssets # Setup cmake project for building for Wii U -cmake -H. -Bbuild-wiiu -GNinja -DCMAKE_TOOLCHAIN_FILE=/opt/devkitpro/cmake/WiiU.cmake # -DCMAKE_BUILD_TYPE:STRING=Release (if you're packaging) +cmake -H. -Bbuild-wiiu -GNinja -DCMAKE_TOOLCHAIN_FILE=/opt/devkitpro/cmake/WiiU.cmake -DENABLE_FLUIDSYNTH=OFF # -DCMAKE_BUILD_TYPE:STRING=Release (if you're packaging) # Build project and generate rpx cmake --build build-wiiu --target soh # --target soh_wuhb (for building .wuhb) @@ -325,6 +329,8 @@ cmake --build build-wiiu --target soh # --target soh_wuhb (for building .wuhb) # To develop the project open the repository in VSCode (or your preferred editor) ``` +_Note: As with Switch, FluidSynth is disabled above (not shipped by devkitpro); drop `-DENABLE_FLUIDSYNTH=OFF` once you've built a fluidsynth for the platform._ + # Compatible Roms See [`supportedHashes.json`](supportedHashes.json) diff --git a/docs/CUSTOM_MUSIC.md b/docs/CUSTOM_MUSIC.md index 234955dbc27..41e6310e976 100644 --- a/docs/CUSTOM_MUSIC.md +++ b/docs/CUSTOM_MUSIC.md @@ -1,18 +1,20 @@ -### Custom Music +# Custom Music We support importing custom [Seq64](https://github.com/sauraen/seq64) files to replace the in game music and fanfares (Sound effect and instrument replacement is currently not supported). First you will need to prepare a folder with the desired sequences. Every sequence requires two files with the same name and different extensions - a `.seq` Seq64 file and a `.meta` plaintext file. These files can be categorically nested in folders if desired, - Retro will recursively search each subfolder it finds. The `.meta` file requires two lines - the first line is the name that will be displayed in the SFX editor, and the second line is the instrument set number in `base16` format. For example, if there is a sequence file `Foo.seq` then you need a meta file `Foo.meta` that could contain: -``` + +```text Awesome Name C ``` Once you have prepared your sequences folder: -1. Download and open [Retro](https://github.com/HarbourMasters/retro/releases). -1. Choose the "Create OTR" option. + +1. Download and open [Retro](https://github.com/HarbourMasters/retro/releases). +1. Choose the "Create OTR" option. 1. Choose the "Custom Sequences" option. 1. Using the file selection screen, choose the sequences folder you prepared in the previous instructions. 1. Click the "Stage Files" button. @@ -22,3 +24,7 @@ Once you have prepared your sequences folder: - This `mods` folder should be in the same folder as your `oot.o2r` file. Assuming you have done everything correctly, boot up SoH and select the SFX Editor from the enhancements dropdown menu. You should now be able to swap out any of the in game sequences/fanfares for the sequences added in your newly generated OTR file. If you have any trouble with this process, please reach out in the support section of the Discord. + +## Use SF2 / SF3 formatted Synth Packs + +See [CUSTOM_SYNTH](CUSTOM_SYNTH.md) diff --git a/docs/CUSTOM_SYNTH.md b/docs/CUSTOM_SYNTH.md new file mode 100644 index 00000000000..d898d5cdfcd --- /dev/null +++ b/docs/CUSTOM_SYNTH.md @@ -0,0 +1,508 @@ +# Custom SoundFonts using FluidSynth + +SoH can play its music through [FluidSynth](https://www.fluidsynth.org/) +using one or more SoundFont (`.sf2` or `.sf3`) files supplied as **synth +packs**. +Each engine instrument can be routed to a specific SF preset, tuned per +pair (gain, pitch shift, reverb / chorus / filter), split across note +ranges, mapped per drum slot, and labeled with a friendly name. This doc +is the user + modder guide. + +--- + +## For users + +### Modern audio pipeline (F32 instead of S16) + +In **Audio Editor -> Audio Options**, tick **Modern audio pipeline +(floating point)**. This switches the audio path to 32-bit float. + +The float pipeline is its own quality win even with no SF loaded: it +processes audio at higher precision, reducing rounding artifacts and +preserving more detail during mixing and resampling. FluidSynth requires +it, so the FluidSynth tab also offers a one-click **Enable Modern +Pipeline** button if you land there first. + +### Enabling the FluidSynth pipeline + +FluidSynth must be enabled at compile time. At the time of writing the +only way to get it is to compile Shipwright from source. Read +[BUILDING](BUILDING.md) for the basics, install +[FluidSynth](https://www.fluidsynth.org/download/), and add +`-DENABLE_FLUIDSYNTH=ON` to the CMake configure command (or set the +option after the first generation). + +With it compiled in, the **Audio Editor** gains a dedicated **FluidSynth** +tab. That's where everything below lives. + +### Adding SoundFont files (SF2 / SF3) + +Both `.sf2` and `.sf3` are accepted. SF3 is the same format with its +samples Ogg/Vorbis-compressed - typically a fraction of the size (a large +GM SoundFont can drop from ~200 MB to ~40 MB) for essentially the same +sound. FluidSynth decompresses SF3 transparently, **provided the +FluidSynth it was linked against was built with libsndfile + Vorbis** +(most distro packages are; a minimal build may not). An `.sf3` that can't +be decoded is skipped with a reason shown in the FluidSynth tab rather +than failing silently. + +Two ways to supply a pack: + +**Loose folder.** Drop a `*.sf2` or `*.sf3` into: + +```paths +/synth-packs/ +``` + +Optionally include a sibling `.json` for per-instrument tuning +(e.g. `MyPack.sf2` + `MyPack.json`). Create the `synth-packs` folder next +to your `mods` folder. + +**Mod archive.** Files inside any mounted `.o2r` archive at: + +```paths +audio/synth//soundfont.sf2 (or soundfont.sf3) +audio/synth//mapping.json (optional) +``` + +Both sources show up in the FluidSynth tab's pack list, tagged `[loose]` +or `[mod]`. Loose packs are listed after mod-supplied ones, both +alphabetised. **Rescan** re-enumerates without a restart, so a +freshly-dropped file becomes visible. Untick a pack to disable it; the +header shows `(N enabled / M discovered)`. + +The float pipeline can run with no packs enabled (no timbre change) - +the synth controls below only matter once at least one pack is on. + +### Stacking multiple packs + +When more than one pack is enabled, FluidSynth searches the last-loaded +pack first for any `(bank, program)` lookup - like the wider mod stack, +**last loaded wins** on collisions. Per-pair pinning (see "Source state" +below) lets a specific pack win regardless of order: picking a preset +binds the row to the pack that owns it. + +### Synth mode and volume + +Two radio buttons at the top of the active-pack controls: + +- **Authentic** - replaces the SF's default velocity / CC7 / CC11 + attenuation modulators with halved-amount versions (curve adapted from + [ANMP](https://github.com/derselbst/ANMP)). Fixes NoteOn velocity at 100 + and routes the shaped value through CC11. Pairs with a console-era reverb + preset. Default. +- **Enhanced** - stock SF modulators; sends the shaped value as NoteOn + velocity so the SF author's own dynamics apply. Pairs with a subtle + reverb. Use this with musically-curated banks (orchestral, SC-55, + MuseScore) where the author's dynamics are what you want. + +Just above the mode buttons is a **Volume** slider. It is **per mode** - +Authentic and Enhanced are calibrated to different baselines (Authentic's +reverb makes it the louder mode), so the slider edits whichever mode is +active. 100% is tuned to roughly match native loudness; the goal is +parity, not a boost. Drag live to hear it. + +### Voice and channel gauges + +Two readouts sit under the mode controls: + +- **FluidSynth voices: A / B** - active voices held by FluidSynth out of + its polyphony limit (default 256). As A approaches B, new NoteOns steal + old voices and dense passages "cut". The text tiers grey -> amber -> + red as you near the cap. If cuts line up with values well *below* the + limit, the bottleneck is audio-thread CPU, not voices. +- **Synth channels: A / B (reclaims: R)** - distinct routed + `(instrument)` pairs each claim one MIDI channel out of 64. At the cap + the pool recycles a channel from a pair that has gone quiet; `reclaims` + counts how often that happened. Sitting at 64 on a long session is + normal. + +### The per-instrument table + +Each row is one engine instrument pair the audio engine has actually +played at least once. The row background tints **green** when it is +currently sounding through the synth, **blue** when sounding native. +Above the table: **Clear list** (forget discovered rows), **Reset all** +(drop personal overrides, restore the active pack's defaults; auto-saved), +and **Export pack mapping...** (modder publishing, see below). + +Columns: + +| Column | What it does | +|----------|----------------------------------------------------------------------------------------------| +| Override | Session-only **Solo** (S) / **Mute** (M) buttons. Warm/red palette = *not saved*. | +| Song | Engine font name (e.g. "Main Theme"). Read-only. | +| Sample | Editable label - type a name like "Lush strings". Hint shows the auto-detected SF sample name; hover for the engine's Low/Mid/High samples. | +| Inst | Engine slot id; `0 (Drum)` / `1 (SFX)` are special. Holds the **Split** / **L/M/H** / **As Drum** (and, for drum rows, **Slots**) buttons; hover for NoteOn stats. | +| Mode | **Native** (engine synth) vs **Synth** (FluidSynth). | +| Gain | Per-instrument synth volume (0..4x). | +| Shift | Pitch shift. Header has a **Semitone** checkbox to switch the column between octaves (+/-8) and semitones (+/-24). Auto-seeded from the engine sample's tuning when you pick a preset (so a substitute lands in the original octave); right-click to re-apply the suggested shift. | +| Preset | Pick an SF preset from any loaded pack (filterable). Selecting also pins the row to that pack. | +| Adv | Per-entry **Adv** popup: Reverb (CC91), Chorus (CC93), Cutoff (CC74), Q (CC71). Drag a slider below 0 to clear that override. | + +**Override column (session-only).** Solo adds the row to the solo set; +while that set is non-empty, every non-soloed row is muted (solo multiple +rows to play them side by side). Mute silences just that row, on both the +engine and synth paths. Neither is saved. The **Clear** button in the +column header wipes all solo/mute state across the table without touching +persisted edits. + +Everything **outside** the Override column auto-saves to +`/fluidsynth_overrides.json` on every edit. There is no Save +button. To roll back persisted edits, use **Reset all** above the table. + +### Note-range splits (melodic) + +Many engine instruments load different samples at low / normal / high +pitch, so a single GM preset sounds right at one octave and wrong at +another. Split a melodic row to assign a different preset per range: + +- **Split** (in the Inst column) bisects the row's current preset into + two note ranges. +- **L/M/H** appears when the engine captured the instrument's own + low/normal/high sample boundaries; it splits into exactly those three + ranges, duplicating the current preset so each range can be reassigned. + +A split row becomes a collapsible header (`N ranges`). Expand it to edit +each range independently: Native/Synth, Gain, **Shift** (per-range octave/ +semitone), Preset, and Adv effects, plus per-range **Split** / **Merge**. +**Flatten** collapses everything back to one full-range entry. + +Ranges are always contiguous and non-overlapping, so the boundary between +two ranges is the only editable value. The first range's low is pinned to +0 and the last range's high to 127 (both grayed); dragging any boundary +moves the neighbouring range's edge with it. **Merge** absorbs the next +(higher) range into this one. + +### Drums + +Engine drums live on instrument slot `0 (Drum)` (sound effects on +`1 (SFX)`). For these the engine's `semitone` byte is a *slot index*, not +a pitch, so they get their own collapsible tree-row: + +- The parent row has a per-instrument **Native / Synth** master and a + **Kit** dropdown (lists the bank-128 percussion presets from loaded + packs). Picking a kit switches the instrument to Synth and applies the + kit to its slots; "None (native)" switches it back. +- The **Slots (N)** button discovers drum slots - it creates one child + entry per slot heard so far. Play the song first, then click it. +- Expand to get one child row per slot: per-slot Solo/Mute, Native/Synth, + a **Drum Sound** dropdown (filterable GM percussion names), per-slot + Gain, and Adv effects. Slot rows are editable only while the + instrument master is Synth; Native greys them out but still lets you + Solo/Mute to isolate a native drum. + +### Treat a melodic instrument as a drum + +Some songs play a percussion hit through a *melodic* instrument slot +rather than the drum bank. The **As Drum** button (Inst column, next to +Split / L/M/H) routes any melodic instrument through the drum path: each +distinct note it plays becomes a slot you map to a GM percussion sound. + +Click **As Drum**, play the part so the notes are heard, then expand the +row and use **Slots (N)** to discover them - the row now behaves exactly +like a drum tree-row (Kit dropdown, per-slot Drum Sound, Solo/Mute). The +**Melodic** button in the Mode column reverts it to normal melodic +routing. Notes you haven't mapped keep playing the original instrument. + +### Source state (pin behavior) + +Picking a preset pins the row to the SF that owns it. The pin persists +through pack toggles; the Preset cell and its tooltip reflect the current +resolution: + +- **Live**: the pinned pack is loaded and the preset is found - the row + plays through that pack. +- **Drift**: the pinned pack is loaded, but a *different* pack now + resolves the same `(bank, program)` first because the load order + changed. Re-pick the preset to refresh. +- **Dead** / missing: the pinned pack is disabled or the preset is gone. + The row plays native until the pack is re-enabled; your tuning waits on + disk. + +### Known limitations + +A generic SoundFont can't fully match the original samples: + +- The engine uses **per-key sample splits** with their own ADSR / pan / + attenuation per range, plus baked filter and reverb shaping. A GM + program plays one sample across the whole range with one envelope. The + note-range split tools above help, but only so far. +- **Slot semantics are font-specific.** The same instrument index means + different things in different engine fonts. Tuning is per-font work - + there's no shared "bank 1 layout". +- A SoundFont *purpose-built* for the game's instrument layout is the + realistic path to "better than native". Synth packs are how those + community projects ship. + +--- + +## For modders + +### Pack layout + +Loose form (for iterating during authoring - no zip step): + +```paths +/synth-packs/MyPack.sf3 (or .sf2) +/synth-packs/MyPack.json (optional, sibling) +``` + +Archive form (for distribution - what **Export .o2r** builds for you): + +```paths +/ + audio/synth/MyPack/soundfont.sf3 (or .sf2) + audio/synth/MyPack/mapping.json (optional) +``` + +The `` segment is the user-visible identifier - a short ASCII +slug like `HD-Orchestra` or `FluidR3-Default`. + +### Authoring loop + +1. Drop your SF into the loose folder. The pack appears in the + FluidSynth tab (hit **Rescan** if needed). +2. Enable just your pack, disable the rest. Open the game and play the + songs you're tuning against (the sequence-preview tab works too). +3. Walk the per-instrument table. For each row: + - Pick a preset from the **Preset** combo. The row is now pinned to + your SF. + - Tune Gain, Shift, and the Adv effect CCs by ear. Split melodic rows + by range, and map drum slots, where the single-preset approach + falls short. + - Type a friendly label in **Sample** so the table reads coherently + when you come back to it. + - Use **Solo** to hear one voice; Mute to drop one out. +4. Every edit auto-saves to `fluidsynth_overrides.json`. When happy, use + **Export pack...** to write a shippable mod. + +### Export pack + +The **Export pack...** button (above the table) opens a dialog prefilled +with the pack's name. It exports the pack's **effective mapping** - every +entry currently **enabled** (routing through synth) for that pack, whether +you hand-picked it or it came from the pack's own loaded `mapping.json`. It +strips the runtime-only flags (`enabled` / `selected`) and writes the pack +name **once** as a `pack_name` header (entries no longer repeat it). The +dialog previews the entry count before you commit. Two outputs: + +- **Export .o2r** (recommended) - zips your soundfont and the mapping into + a single shareable mod at: + + ```paths + /mods/.o2r + ``` + + with the files placed at `audio/synth//soundfont.sf3` (or + `.sf2`) and `.../mapping.json`. This is the artifact you upload. It + loads on the **next launch** (mods are mounted at startup). + +- **JSON only** - writes just the mapping beside your loose soundfont at: + + ```paths + /synth-packs/.json + ``` + + Picked up on **Rescan**, no restart needed - handy for iterating before + you build the final `.o2r`. + +The pack name must match the soundfont's name (`.sf3` loose, or +the `audio/synth//` folder in the archive); that name is what +the loader uses to tie the mapping to the soundfont. + +### `mapping.json` schema + +Same shape as the user's `fluidsynth_overrides.json` (schema **version +2**). Your pack's mapping is a chain layer that overlays defaults; the +user's file overlays yours. Pack-shipped entries reload on every +pack-enable and are never written back to the user file. + +```json +{ + "version": 2, + "pack_name": "MyPack", + "entries": [ + { + "fontId": 6, + "instOrWave": 12, + "bank": 0, + "program": 46, + "preset_name": "Orchestral Harp", + "display_name": "Lush strings", + "gain": 0.85, + "transpose": 0, + "reverb": 64, + "chorus": 16, + "filter_cutoff": 96, + "filter_q": 32 + } + ], + "drum_channels_synth": [ + { "fontId": 9, "instOrWave": 0 } + ], + "forced_drums": [ + { "fontId": 9, "instOrWave": 3 } + ] +} +``` + +| Key | Type | Meaning | +|-----------------|--------------------|---------------------------------------------------------------------| +| `version` | int | Schema version. Use `2`. | +| `pack_name` | string | Top-level header naming the pack (added by Export). This is the authoritative owner for every entry; the loader also derives it from the pack's file/folder name, so a rename is safe. | +| `fontId` | int 0-63 | Engine sound-font index. Required. 0-37 are vanilla. | +| `instOrWave` | int 0-255 | Engine instrument slot. Required. `0` = drum bank, `1` = SFX bank. | +| `pack` | string | Per-entry pack owner. **Optional / legacy** - `pack_name` (or the file name) supplies this now. Only read when there is no `pack_name` header. | +| `bank` | int 0-255 | SF bank. Default 0 (GM melodic); 128 = GM drums. | +| `program` | int 0-127 | SF program inside that bank. | +| `preset_name` | string | Human-readable SF preset name. Metadata only - the UI uses it to detect drift. | +| `display_name` | string | Friendly label shown in the Sample column. | +| `gain` | float | Per-pair volume multiplier. Omit = 1.0x. | +| `transpose` | int (semitones) | Pitch shift. Omit = 0. | +| `reverb` | int 0-127 | CC91 reverb send. Omit to leave the channel default. | +| `chorus` | int 0-127 | CC93 chorus send. | +| `filter_cutoff` | int 0-127 | CC74 low-pass cutoff (64 = no shift from the SF default). | +| `filter_q` | int 0-127 | CC71 low-pass resonance (64 = no shift). | +| `note_low` | int 0-127 | Low end of this entry's engine-semitone range. Omit = 0 (full range). | +| `note_high` | int 0-127 | High end of the range. Omit = 127. | +| `fixed_note` | int 0-127 | Play this exact note instead of the engine pitch (drum slots / tuned percussion). Omit = derive from pitch. | +| `route` | "synth" / "native" | Per-entry route. Omit = synth. | + +`drum_channels_synth` is a separate top-level array listing the +`(fontId, instOrWave)` drum channels whose per-instrument master is set +to Synth (absent = Native). + +`forced_drums` is a separate top-level array listing the melodic pairs +(`instOrWave >= 2`) flagged **As Drum** - each plays through the drum path +with its notes mapped to GM percussion via per-slot `fixed_note` entries. + +Omit any field you don't want to set - partial entries layer cleanly over +the chain below them. Multiple entries can share one `(fontId, +instOrWave)` pair: distinct `note_low` values become note-range splits or +drum slots. + +### Resolution model + +At play time, for each engine `(fontId, instOrWave)` pair: + +1. Among the pair's entries, keep those that are enabled and resolvable + (the pinned pack is loaded AND it actually has that `(bank, program)`). +2. Zero matches -> the row plays native. +3. For a split/drum pair, the entry whose `[note_low..note_high]` covers + the incoming semitone wins; ranges are kept adjacent and + non-overlapping. +4. On a plain collision, the entry from the **last-loaded** pack wins. + +Pinning uses FluidSynth's `program_select`, so even if another loaded SF +has the same `(bank, program)`, your pack wins for the rows you pinned. +A dead entry (pack disabled / preset gone) stays on disk and springs back +when the source pack is re-enabled. + +### Override chain (highest priority wins) + +1. Reset-to-factory state +2. Built-in defaults (currently empty) +3. Pack `mapping.json` files, in enabled-pack order +4. User's `fluidsynth_overrides.json` + +So a user's per-pair edit always wins over your pack's defaults, and a +later-loaded pack's mapping wins over an earlier one's. Users can +re-tune individual pairs to taste without touching your pack. + +### Packaging + +The easiest path is the **Export .o2r** button (see Export pack above) - +it writes a ready-to-share `mods/.o2r` with your soundfont and +mapping already at the right paths inside. Users drop that `.o2r` into +their own `/mods/` and it loads on next launch. + +To build one by hand instead, lay out the inner shape and zip it - +standard `zip` produces a valid `.o2r`: + +```bash +cd my-pack/ # contains audio/synth/MyPack/{soundfont.sf3,mapping.json} +zip -r ../MyPack.o2r audio/ +``` + +#### Shipping more than one soundfont + +Each soundfont is its own pack (one `audio/synth//` folder = one +enableable row). **Export .o2r** writes one pack per file, so the simple +way to ship two soundfonts is two `.o2r` files - export each pack, ship +both. They are independent: users can enable either or both. + +If you'd rather hand-pack them into a single `.o2r`, put each in its own +folder and zip together - the archive name itself doesn't matter, only the +inner folders: + +```paths +/ + audio/synth/SoundBankA/{soundfont.sf2, mapping.json} + audio/synth/SoundBankB/{soundfont.sf3, mapping.json} +``` + +They still appear as two separate rows (SoundBankA, SoundBankB), not one +merged pack. + +> **Loose + mod with the same name.** A loose `.sf3` and a shipped +> `.o2r` both show in the list (tagged `[loose]` and `[mod]`) and +> enable/disable independently. They do share the same internal pack name, +> so if you leave *both* enabled their mappings overlay and routing is +> ambiguous - keep only one of the two enabled. The usual flow is to author +> against the loose copy, then disable it once the `.o2r` is built. + +### Caveats + +- **New songs with custom engine fonts are not reliably supported.** + Custom fonts (under `custom/fonts/`) are assigned a `fontId` by archive + listing order, which is effectively arbitrary. Two mods with custom + fonts can shift each other's `fontId` unpredictably, making any mapping + against those IDs fragile. A new song that only references vanilla fonts + (0-37) is not affected. +- Total fonts are capped at **64** (38 vanilla + 26 modded slots). +- **Sample names**: many engine slots have no captured sample name in the + binary asset. The Sample column shows `(no sample name)` then - which + is where `display_name` becomes especially useful for walking the + table. Labels can be shipped in the mapping. + +#### Engine quirks the substitution model can't paper over + +These are limits of the SF-substitution approach, not bugs. A pack +author hits them as "this row refuses to behave". + +- **Drum and SFX banks use slot indices, not pitches.** On `0 (Drum)` + and `1 (SFX)` the engine's `semitone` byte selects a sample from a + per-font drum/SFX table; it is not a chromatic pitch. The drum tree-row + handles this: discover the slots, then map each to a GM percussion + sound (or a tuned pitch). A plain melodic preset on these rows would + play "right rhythm, wrong sound at random pitches", so use the per-slot + Drum Sound mapping, not the melodic Preset combo. SFX is usually best + left Native. + +- **Some audible percussion lives on melodic-instrument rows.** A few + songs author a drum as a regular melodic instrument whose sample slots + hold drum hits (the engine pitch then has no relation to the sample's + real fundamental). Mapping those to GM percussion cleanly is not yet + first-class - for now, leave such a row Native or approximate it with a + tuned preset. + +- **Single-NoteOn polyphonic samples cannot be substituted.** A few + instruments bake a chord into one sample - one engine NoteOn fires what + sounds like a multi-voice stack. The substitution model fires one + preset voice per engine event, so reproducing this needs either a + custom SF preset whose single voice already contains the chord, or a + sequence rewrite emitting N parallel NoteOns. Neither is in this tool's + scope - leave the row Native. + +--- + +## See also + +- [derselbst/**ANMP**](https://github.com/derselbst/ANMP) - the closest + analog: a FluidSynth-based player for sequenced console-era music. + Originator of the halved-attenuation volume curve used by Authentic mode. +- [**ANMP**'s wiki](https://github.com/derselbst/ANMP/wiki), in particular + its [Reproducing console OSTs + accurately](https://github.com/derselbst/ANMP/wiki/Reproducing-N64-OSTs-accurately) + page - reverb-preset research that informs the Authentic mode default. diff --git a/linux-build-deps/apt.txt b/linux-build-deps/apt.txt index d90872d59f0..b13c58d2ab9 100644 --- a/linux-build-deps/apt.txt +++ b/linux-build-deps/apt.txt @@ -1 +1 @@ -libusb-dev libusb-1.0-0-dev libsdl2-dev libsdl2-net-dev libpng-dev libglew-dev nlohmann-json3-dev libtinyxml2-dev libspdlog-dev ninja-build libogg-dev libopus-dev opus-tools libopusfile-dev libvorbis-dev libespeak-ng-dev libzip-dev zipcmp zipmerge ziptool git cmake lsb-release \ No newline at end of file +libusb-dev libusb-1.0-0-dev libsdl2-dev libsdl2-net-dev libpng-dev libglew-dev nlohmann-json3-dev libtinyxml2-dev libspdlog-dev ninja-build libogg-dev libopus-dev opus-tools libopusfile-dev libvorbis-dev libespeak-ng-dev libzip-dev zipcmp zipmerge ziptool git cmake lsb-release libfluidsynth-dev pkg-config \ No newline at end of file diff --git a/linux-build-deps/dnf.txt b/linux-build-deps/dnf.txt index 70b3133f17a..b9f60cfbc57 100644 --- a/linux-build-deps/dnf.txt +++ b/linux-build-deps/dnf.txt @@ -1 +1 @@ -git cmake ninja-build lsb_release SDL2-devel SDL2_net-devel libpng-devel libzip-devel libzip-tools nlohmann-json-devel tinyxml2-devel spdlog-devel opusfile-devel libvorbis-devel +git cmake ninja-build lsb_release SDL2-devel SDL2_net-devel libpng-devel libzip-devel libzip-tools nlohmann-json-devel tinyxml2-devel spdlog-devel opusfile-devel libvorbis-devel fluidsynth-devel pkgconf-pkg-config diff --git a/linux-build-deps/pacman.txt b/linux-build-deps/pacman.txt index 6c1cd5bb0f1..3d8bf62d47c 100644 --- a/linux-build-deps/pacman.txt +++ b/linux-build-deps/pacman.txt @@ -1 +1 @@ -git cmake ninja lsb-release sdl2 libpng libzip nlohmann-json tinyxml2 spdlog sdl2_net opusfile libvorbis python +git cmake ninja lsb-release sdl2 libpng libzip nlohmann-json tinyxml2 spdlog sdl2_net opusfile libvorbis python fluidsynth pkgconf diff --git a/linux-build-deps/zypper.txt b/linux-build-deps/zypper.txt index 43932667dc7..7da52f5482e 100644 --- a/linux-build-deps/zypper.txt +++ b/linux-build-deps/zypper.txt @@ -1 +1 @@ -git cmake ninja SDL2-devel SDL2_net-devel libpng16-devel libzip-devel libzip-tools nlohmann_json-devel tinyxml2-devel spdlog-devel libogg-devel libvorbis-devel libopus-devel opusfile-devel glew-devel libglvnd-devel Mesa-libGLESv2-devel +git cmake ninja SDL2-devel SDL2_net-devel libpng16-devel libzip-devel libzip-tools nlohmann_json-devel tinyxml2-devel spdlog-devel libogg-devel libvorbis-devel libopus-devel opusfile-devel glew-devel libglvnd-devel Mesa-libGLESv2-devel fluidsynth-devel pkg-config diff --git a/soh/CMakeLists.txt b/soh/CMakeLists.txt index 16da97afadb..3aef186ba86 100644 --- a/soh/CMakeLists.txt +++ b/soh/CMakeLists.txt @@ -235,6 +235,51 @@ if (CMAKE_SYSTEM_NAME STREQUAL "Windows") use_props(${PROJECT_NAME} "${CMAKE_CONFIGURATION_TYPES}" "${DEFAULT_CXX_PROPS}") endif() +# FluidSynth synthesis backend. +# Resolution order: installed CMake config -> pkg-config -> build from source. +# The from-source fallback is for macOS: the MacPorts fluidsynth port +# hard-depends on portaudio and glib2, which fail to build. +# FluidSynth with osal=cpp11 avoids the glib dependency. +if(ENABLE_FLUIDSYNTH) + find_package(FluidSynth CONFIG QUIET) + if(NOT TARGET FluidSynth::libfluidsynth) + find_package(PkgConfig QUIET) + if(PKG_CONFIG_FOUND) + pkg_check_modules(FLUIDSYNTH IMPORTED_TARGET fluidsynth) + endif() + endif() + + if(TARGET FluidSynth::libfluidsynth) + target_link_libraries(${PROJECT_NAME} PRIVATE FluidSynth::libfluidsynth) + elseif(TARGET PkgConfig::FLUIDSYNTH) + target_link_libraries(${PROJECT_NAME} PRIVATE PkgConfig::FLUIDSYNTH) + else() + message(STATUS "FluidSynth not found; building it from source (FetchContent)") + include(FetchContent) + # Static so the result links straight into soh with no dylib to bundle; + # only libsndfile stays dynamic (handled by the platform's normal bundling). + set(BUILD_SHARED_LIBS OFF) + set(osal cpp11 CACHE STRING "" FORCE) + set(enable-framework OFF CACHE BOOL "" FORCE) + set(enable-libsndfile ON CACHE BOOL "" FORCE) + set(enable-portaudio OFF CACHE BOOL "" FORCE) + set(enable-readline OFF CACHE BOOL "" FORCE) + set(enable-dbus OFF CACHE BOOL "" FORCE) + set(enable-jack OFF CACHE BOOL "" FORCE) + set(enable-sdl3 OFF CACHE BOOL "" FORCE) + set(enable-pulseaudio OFF CACHE BOOL "" FORCE) + FetchContent_Declare(fluidsynth + GIT_REPOSITORY https://github.com/FluidSynth/fluidsynth.git + GIT_TAG v2.5.5 + GIT_SHALLOW TRUE) + FetchContent_MakeAvailable(fluidsynth) + target_link_libraries(${PROJECT_NAME} PRIVATE libfluidsynth) + endif() + target_compile_definitions(${PROJECT_NAME} PRIVATE ENABLE_FLUIDSYNTH=1) +else() + target_compile_definitions(${PROJECT_NAME} PRIVATE ENABLE_FLUIDSYNTH=0) +endif() + set(ROOT_NAMESPACE soh) if (CMAKE_SYSTEM_NAME STREQUAL "Windows") diff --git a/soh/soh/Enhancements/audio/AudioEditor.cpp b/soh/soh/Enhancements/audio/AudioEditor.cpp index 935c7927aab..7d617fd9d6f 100644 --- a/soh/soh/Enhancements/audio/AudioEditor.cpp +++ b/soh/soh/Enhancements/audio/AudioEditor.cpp @@ -1,9 +1,12 @@ #include "AudioEditor.h" #include "sequence.h" +#include #include #include #include +#include +#include #include #include "soh/ShipUtils.h" #include "soh/OTRGlobals.h" @@ -16,6 +19,712 @@ #include "soh/Enhancements/game-interactor/GameInteractor.h" #include "soh/Enhancements/randomizer/SeedContext.h" +#if ENABLE_FLUIDSYNTH +#include "soh/Enhancements/audio/MidiSynthManager.h" +#include "soh/Enhancements/audio/FluidSynth.h" +#include "MidiTranslator.h" +#include "GmInstrumentMap.h" +#include "InstrumentNames.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" void SOH_MidiTranslator_Reset(); + +namespace { +// Synth packs stack from two sources: mod-supplied (audio/synth// in a +// mounted .o2r) then loose .sf2/.sf3 in /synth-packs/. FluidSynth +// walks SFs in reverse load order, so the last enabled pack wins on collisions. +constexpr const char* kSynthPackRoot = "audio/synth"; +constexpr const char* kSynthPackSf2Name = "soundfont.sf2"; +// Archive glob matching either soundfont.sf2 or soundfont.sf3. Both names are +// the same length, so the suffix-stripping below can keep using +// kSynthPackSf2Name for the length math. +constexpr const char* kSynthPackSfGlob = "soundfont.sf[23]"; +constexpr const char* kSynthPackJsonName = "mapping.json"; +constexpr const char* kLooseSynthPacksDirName = "synth-packs"; + +struct SynthPackEntry { + enum class Source { Archive, Loose }; + std::string name; // display name + key used in the disabled-set CSV + Source source; + // Archive: virtual resource paths inside the archive. + // Loose: absolute filesystem paths. + std::string sfPath; + std::string mappingPath; // empty if no mapping json is available +}; + +// One row per (sfontId, bank, program) across loaded SFs; filled after the load +// loop, consumed by the bypass UI. Names come from the SF phdr (the author's). +struct LoadedPresetEntry { + int sfontId; + std::string packName; + int bank; + int program; + std::string name; +}; +static std::vector sLoadedPresets; + +// Derived from sLoadedPresets — unique (sfontId, bank) tuples in load +// order. Drives the Bank/Pack combo. Kept in step with sLoadedPresets. +struct BankSelectorEntry { + int sfontId; + std::string packName; + int bank; +}; +static std::vector sBankSelectors; + +// Auto-save model: every UI edit that touches a persisted field commits immediately +// to fluidsynth_overrides.json, keeping in-memory state == on-disk state (pack +// toggles reset and re-overlay from disk, so unsaved work must not exist). Drag and +// slider widgets save once per drag via IsItemDeactivatedAfterEdit(); clicks inline. +void AutoSaveOverrides() { + auto path = Ship::Context::GetPathRelativeToAppDirectory("fluidsynth_overrides.json", appShortName); + SOH::MidiTranslator::Instance().SaveOverridesToFile(path); +} + +// Session-only Override-column state, never persisted. +// sSoloedPairs: pairs the user has soloed; when non-empty, every non-soloed +// discovered pair is force-muted. Multiple rows can be soloed at once. +// sExplicitMutedPairs: per-row mutes from the Mute button on Native rows. (Synth +// rows mute via the temp-volume 0.0 stop instead.) Stacks with solo. +static std::set> sSoloedPairs; +static std::set> sExplicitMutedPairs; + +// Per-drum-slot analogues, keyed by (fontId, instOrWave, slot/noteLow), so an +// individual drum sound can be soloed/muted to isolate it. A soloed slot +// counts toward the global solo state (it mutes other pairs and other slots); +// see the effective-mute apply pass. +static std::set> sSoloedSlots; +static std::set> sExplicitMutedSlots; + +// Export-pack-mapping popup state. Buffers are session-scoped: the popup +// prefills sExportPackName from the user's most common selected pack each +// time it opens, and sExportStatus carries the last write result so the +// user can re-open and re-confirm without losing the path. +static char sExportPackName[128] = ""; +static char sExportStatus[512] = ""; + +// Returns every pack the user could enable: archive-supplied first (alpha +// sorted), then loose-folder SFs (alpha sorted). Packs are not filtered +// by the disabled-set CVar here — callers decide whether to apply that +// filter (UI shows everything; apply path skips disabled rows). +std::vector EnumerateSynthPacks() { + std::vector result; + + // ── Archive-supplied packs ─────────────────────────────────────── + auto archives = Ship::Context::GetRawInstance()->GetResourceManager()->GetArchiveManager(); + if (auto matches = archives->ListFiles(std::string(kSynthPackRoot) + "/*/" + kSynthPackSfGlob)) { + const size_t prefixLen = std::strlen(kSynthPackRoot) + 1; + const size_t suffixLen = std::strlen(kSynthPackSf2Name) + 1; + std::vector arc; + arc.reserve(matches->size()); + for (const auto& path : *matches) { + if (path.size() <= prefixLen + suffixLen) + continue; + std::string name = path.substr(prefixLen, path.size() - prefixLen - suffixLen); + SynthPackEntry e; + e.name = name; + e.source = SynthPackEntry::Source::Archive; + e.sfPath = path; + e.mappingPath = std::string(kSynthPackRoot) + "/" + name + "/" + kSynthPackJsonName; + arc.push_back(std::move(e)); + } + std::sort(arc.begin(), arc.end(), + [](const SynthPackEntry& a, const SynthPackEntry& b) { return a.name < b.name; }); + // Dedupe by name across multiple archives shipping the same pack — + // ListFiles returns one entry per archive that contains the file, + // and we only want one row in the UI. + arc.erase(std::unique(arc.begin(), arc.end(), + [](const SynthPackEntry& a, const SynthPackEntry& b) { return a.name == b.name; }), + arc.end()); + for (auto& e : arc) + result.push_back(std::move(e)); + } + + // ── Loose folder ───────────────────────────────────────────────── + // The folder is created lazily — its absence is the normal first-run + // state and not an error. We never write to the folder; the user owns it. + std::string looseDirStr = Ship::Context::GetPathRelativeToAppDirectory(kLooseSynthPacksDirName, appShortName); + std::filesystem::path looseDir(looseDirStr); + std::error_code ec; + if (std::filesystem::is_directory(looseDir, ec)) { + std::vector loose; + for (const auto& entry : std::filesystem::directory_iterator(looseDir, ec)) { + if (ec) + break; + if (!entry.is_regular_file(ec)) + continue; + auto ext = entry.path().extension().string(); + // Case-insensitive .sf2/.sf3 match — Windows users often have .SF2 etc. + std::string extLower = ext; + std::transform(extLower.begin(), extLower.end(), extLower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (extLower != ".sf2" && extLower != ".sf3") + continue; + + SynthPackEntry e; + e.name = entry.path().stem().string(); + e.source = SynthPackEntry::Source::Loose; + e.sfPath = entry.path().string(); + std::filesystem::path jsonPath = entry.path(); + jsonPath.replace_extension(".json"); + if (std::filesystem::exists(jsonPath, ec)) { + e.mappingPath = jsonPath.string(); + } + loose.push_back(std::move(e)); + } + std::sort(loose.begin(), loose.end(), + [](const SynthPackEntry& a, const SynthPackEntry& b) { return a.name < b.name; }); + for (auto& e : loose) + result.push_back(std::move(e)); + } + + return result; +} + +// Disabled-set CSV key. Archive packs key on the bare name; loose packs get a +// "loose:" prefix so a loose .sf3 and a shipped .o2r toggle independently. +std::string PackKey(const SynthPackEntry& e) { + return e.source == SynthPackEntry::Source::Loose ? ("loose:" + e.name) : e.name; +} + +// Disabled-pack CVar: comma-separated pack keys (see PackKey), empty = all enabled. +// Plain CSV (names are practically alphanumeric); empty entries tolerated on parse. + +std::set ParseDisabledPacksCSV() { + std::set result; + std::string csv = CVarGetString(CVAR_AUDIO("FluidSynthDisabledPacks"), ""); + size_t start = 0; + while (start <= csv.size()) { + size_t comma = csv.find(',', start); + std::string name = csv.substr(start, comma == std::string::npos ? std::string::npos : comma - start); + if (!name.empty()) + result.insert(std::move(name)); + if (comma == std::string::npos) + break; + start = comma + 1; + } + return result; +} + +void WriteDisabledPacksCSV(const std::set& disabled) { + std::string csv; + for (const auto& n : disabled) { + if (!csv.empty()) + csv += ","; + csv += n; + } + CVarSetString(CVAR_AUDIO("FluidSynthDisabledPacks"), csv.c_str()); +} + +bool IsPackDisabled(const std::string& name) { + return ParseDisabledPacksCSV().count(name) > 0; +} + +void SetPackDisabled(const std::string& name, bool disabled) { + auto cur = ParseDisabledPacksCSV(); + if (disabled) + cur.insert(name); + else + cur.erase(name); + WriteDisabledPacksCSV(cur); +} + +// Read the SF / mapping bytes for a pack into memory. Archive packs go +// through ArchiveManager::LoadFile; loose packs are read straight off the +// filesystem. Returns an empty vector when the file is missing — the caller +// distinguishes "load failed" from "no mapping" by the *Path field of the +// entry (loose: jsonPath empty → no mapping; archive: probe the load). +std::vector ReadPackFile(const SynthPackEntry& entry, bool wantSf) { + std::vector bytes; + const std::string& path = wantSf ? entry.sfPath : entry.mappingPath; + if (path.empty()) + return bytes; + + if (entry.source == SynthPackEntry::Source::Archive) { + auto archives = Ship::Context::GetRawInstance()->GetResourceManager()->GetArchiveManager(); + auto file = archives->LoadFile(path); + if (!file || !file->Buffer || file->Buffer->size() <= file->BufferOffset) { + return bytes; + } + const uint8_t* data = reinterpret_cast(file->Buffer->data()) + file->BufferOffset; + const size_t size = file->Buffer->size() - file->BufferOffset; + bytes.assign(data, data + size); + } else { + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) + return bytes; + in.seekg(0, std::ios::end); + std::streamoff len = in.tellg(); + if (len <= 0) + return bytes; + in.seekg(0, std::ios::beg); + bytes.resize(static_cast(len)); + in.read(reinterpret_cast(bytes.data()), len); + if (!in) + bytes.clear(); + } + return bytes; +} + +// Filter EnumerateSynthPacks() down to the rows the user has left enabled. +// Discovery order is preserved (mods first, loose second), which is also +// the SF load order — and therefore the priority order FluidSynth uses +// for collision resolution (last-loaded wins). +std::vector EnabledPacksInOrder() { + auto all = EnumerateSynthPacks(); + auto disabled = ParseDisabledPacksCSV(); + std::vector out; + out.reserve(all.size()); + for (auto& e : all) { + if (!disabled.count(PackKey(e))) + out.push_back(std::move(e)); + } + return out; +} + +// One-click pack publish: bundle a pack's soundfont + a freshly-built +// mapping.json into a single .o2r under mods/. The .o2r is the +// shippable artifact -- dropped in mods/ it loads on the next launch and is +// discovered through the archive path (which keys on soundfont.sf[23]), so it +// sidesteps the loose-folder sibling-naming the manual mapping export needs. +// Returns true on success; `outMsg` always carries a user-facing summary or +// the failure reason (ASCII only -- shown in the ImGui popup). +bool ExportPackO2r(const std::string& packName, std::string& outMsg) { + if (packName.empty()) { + outMsg = "Enter a pack name first."; + return false; + } + + // 1. Locate the source soundfont for this pack (the .sf2/.sf3 the entries + // were authored against) and read its bytes. + std::vector sfBytes; + std::string sfExt = ".sf2"; + { + const auto all = EnumerateSynthPacks(); + const SynthPackEntry* match = nullptr; + for (const auto& e : all) { + if (e.name == packName) { + match = &e; + break; + } + } + if (!match) { + outMsg = "No discovered pack named '" + packName + + "' to read a soundfont from. Drop its .sf2/.sf3 in synth-packs/ first."; + return false; + } + sfBytes = ReadPackFile(*match, /*wantSf=*/true); + if (sfBytes.empty()) { + outMsg = "Could not read the soundfont for '" + packName + "'."; + return false; + } + auto dot = match->sfPath.rfind('.'); + if (dot != std::string::npos) { + std::string ext = match->sfPath.substr(dot); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (ext == ".sf2" || ext == ".sf3") + sfExt = ext; + } + } + + // 2. Build the mapping.json in memory. + int nEntries = 0; + std::string mappingJson = SOH::MidiTranslator::Instance().BuildPackMappingJson(packName, nEntries); + if (nEntries <= 0) { + outMsg = "No exportable entries for '" + packName + "'. Pick presets for it (enabled + selected) first."; + return false; + } + + // 3. Write both into a fresh .o2r under mods/. Overwrite any prior + // export so re-running is idempotent. + std::string modsDir = Ship::Context::GetPathRelativeToAppDirectory("mods", appShortName); + std::error_code ec; + std::filesystem::create_directories(modsDir, ec); + std::string o2rPath = (std::filesystem::path(modsDir) / (packName + ".o2r")).generic_string(); + std::filesystem::remove(o2rPath, ec); + + auto archive = std::make_shared(o2rPath); + if (!archive->Open()) { + outMsg = "Could not create " + o2rPath; + return false; + } + const std::string base = std::string("audio/synth/") + packName + "/"; + std::vector jsonBytes(mappingJson.begin(), mappingJson.end()); + bool ok = + archive->WriteFile(base + "mapping.json", jsonBytes) && archive->WriteFile(base + "soundfont" + sfExt, sfBytes); + archive->Close(); + if (!ok) { + outMsg = "Failed writing into " + o2rPath + " (see log)."; + return false; + } + + outMsg = "Wrote " + std::to_string(nEntries) + " entries + soundfont (" + + std::to_string(sfBytes.size() / (1024 * 1024)) + " MiB) to:\n" + o2rPath + + "\nLoads on next launch; share this .o2r to publish the pack."; + return true; +} + +// Push the SF stack + load order into the translator and recompute each entry's +// sfontId. Called after any change to entry resolution (pack load/unload, file +// load, user pick); sLoadedPresets validates entries, packs order breaks ties. +void RefreshEntryResolution(const std::vector& packs) { + auto& tr = SOH::MidiTranslator::Instance(); + std::vector order; + order.reserve(packs.size()); + std::set loaded; + for (const auto& p : packs) { + order.push_back(p.name); + loaded.insert(p.name); + } + tr.SetPackLoadOrder(order); + tr.RemoveModEntriesNotIn(loaded); + + std::vector refs; + refs.reserve(sLoadedPresets.size()); + for (const auto& lp : sLoadedPresets) { + refs.push_back({ lp.sfontId, lp.packName, lp.bank, lp.program }); + } + tr.RefreshEntrySfontIds(refs); + tr.RecomputeAllActive(); +} + +// Common prefix used by ReapplyOverrideChain and ResetToPackBaseline: +// wipe in-memory state, then layer each enabled pack's mapping.json (in +// the same order as the SF load). The user JSON layer (if any) is what +// the two callers differ on. +void ApplyBaselineOnly(const std::vector& packs) { + SOH::MidiTranslator::Instance().ResetAllOverrides(); + + for (const auto& pack : packs) { + if (pack.mappingPath.empty()) + continue; // SF-only pack is valid + auto bytes = ReadPackFile(pack, /*wantSf=*/false); + if (bytes.empty()) + continue; + std::string json(reinterpret_cast(bytes.data()), bytes.size()); + // pack.name is the authoritative owner (loose stem / archive folder), + // so the mapping.json doesn't need a per-entry "pack" and a renamed + // pack still resolves. + SOH::MidiTranslator::Instance().ApplyOverridesFromString(json, pack.name); + } +} + +// Apply the override chain in precedence order: +// 1. Reset to factory state (Auto / 1.0× / -1 / 0) +// 2. Each enabled pack's mapping.json — overlays in load order +// 3. User's fluidsynth_overrides.json — wins over all the above +// Called at startup and whenever the enabled-pack set changes, so the +// live translator state always reflects the current source-of-truth chain. +void ReapplyOverrideChain(const std::vector& packs) { + ApplyBaselineOnly(packs); + SOH::MidiTranslator::Instance().ApplyOverridesFromFile( + Ship::Context::GetPathRelativeToAppDirectory("fluidsynth_overrides.json", appShortName)); + // Entry storage and the pack load order just changed — re-derive + // sfontIds and the active-entry cache so the audio thread sees a + // consistent state on the next note. + RefreshEntryResolution(packs); +} + +void ReapplyOverrideChain() { + ReapplyOverrideChain(EnabledPacksInOrder()); +} + +// Wipe the user's customisations from in-memory state and restore the +// pack-derived baseline (built-in defaults + every enabled pack's +// mapping.json). User's on-disk fluidsynth_overrides.json is not touched +// — the user has to click Save afterward to persist the cleared state. +void ResetToPackBaseline() { + auto packs = EnabledPacksInOrder(); + ApplyBaselineOnly(packs); + // ApplyBaselineOnly only touches the entry storage; we still need to + // refresh sfontIds and the active-entry cache, otherwise the + // freshly-loaded mod entries have sfontId=-1 and resolution finds + // nothing. + RefreshEntryResolution(packs); +} + +// Status line surfaced by the FluidSynth tab so the user sees what's +// active right now and what (if anything) failed. Written by the apply / +// reconcile paths, read by the tab UI. Plain-string status keeps the +// state model trivial; isError just toggles the colour. +struct PipelineStatus { + std::string message; + bool isError = false; + // Per-pack skip reasons (": "). A pack that fails to load + // for any reason is skipped (the others still load); each skip is recorded + // here and rendered amber under the main status line so a partial failure + // is visible on screen, not just in spdlog. + std::vector warnings; +}; +static PipelineStatus sLastStatus; + +// Setting a fresh status clears any prior skip list — stale warnings from a +// previous apply must not linger once the pipeline is reconciled again. +void SetStatus(std::string msg, bool isError = false) { + sLastStatus.message = std::move(msg); + sLastStatus.isError = isError; + sLastStatus.warnings.clear(); +} + +// Attach the skipped-pack list to the current status. Call AFTER SetStatus +// (which clears it). Empty list = no warnings shown. +void SetStatusWarnings(std::vector warnings) { + sLastStatus.warnings = std::move(warnings); +} + +// Effective per-mode global synth gain handed to the translator. The on-screen +// slider is a relative level (1.0 = 100% = matched to native); each mode's real +// calibration to native loudness is hidden here so the UI never shows the raw +// values. Authentic is the louder mode (reverb), so it needs the deeper trim. +static float ComputeSynthGlobalGain(SOH::SynthMode mode) { + // Per-mode constants that match synth loudness to the native engine. The + // exponent differs because loudness rides a different FluidSynth curve per mode: + // Authentic: loudness via CC11 through the halved (480 cB) modulator, so + // amplitude is linear in the control. Never clamps. + // Enhanced: loudness via NoteOn velocity through the stock concave (960 cB) + // modulator, so amplitude goes as the square; only the loudest notes clip. + // See MidiTranslator::ProcessNote and FluidSynth::InstallLinearVelocityModulators. + constexpr float kGainCalAuthentic = 0.55f; + constexpr float kGainCalEnhanced = 1.107f; + const bool enhanced = (mode == SOH::SynthMode::Enhanced); + const float rel = + CVarGetFloat(enhanced ? CVAR_AUDIO("FluidSynthGainEnhanced") : CVAR_AUDIO("FluidSynthGainAuthentic"), 1.0f); + return rel * (enhanced ? kGainCalEnhanced : kGainCalAuthentic); +} + +// FluidSynth's master output gain tracks the Master Volume slider so the synth +// scales exactly as the native engine does (Master is applied per native note in +// audio_playback.c; the synth renders its own PCM downstream of that). Master is +// mode-neutral, like synth.gain, so it lives here and NOT in the per-mode trim +// (ComputeSynthGlobalGain). Default 40 mirrors the slider's default. +static float SynthMasterGainFromCVar() { + return CVarGetInteger(CVAR_SETTING("Volume.Master"), 40) / 100.0f; +} + +// Apply the Modern pipeline + synth config: install FluidSynth and stack every +// enabled pack's SF (with none enabled, native plays unchanged). Returns false +// -- surfaced as the checkbox flipping back off -- when it can't apply. +bool ApplyFluidSynthFromCVars() { + auto audioPlayer = Ship::Context::GetRawInstance()->GetAudio()->GetAudioPlayer(); + if (!audioPlayer) { + SPDLOG_INFO("[AudioEditor] Float audio: audio player not ready, skipping apply"); + SetStatus("Audio player not ready.", true); + return false; + } + // No float-mode toggle here: the device runs at the chosen rate and the audio + // thread always resamples up to it, so installing a synth is the whole job. + auto packs = EnabledPacksInOrder(); + + // Drop the prior synth first so the audio thread sees silence, not two synths + // overlapping; the old shared_ptr frees once the manager releases it. + SOH::MidiSynthManager::Instance().SetSynth(nullptr); + + if (packs.empty()) { + // Float-only mode: native engine through the float path, no SF layer. + SOH_MidiTranslator_Reset(); + sLoadedPresets.clear(); + sBankSelectors.clear(); + SPDLOG_INFO("[AudioEditor] Float audio: pipeline active (no synth packs enabled)"); + SetStatus("Modern audio pipeline active. No synth packs enabled."); + return true; + } + + // Log the intended load order. The last entry has highest priority in + // FluidSynth's reverse-load-order preset lookup. + { + std::string orderLog; + for (const auto& p : packs) { + if (!orderLog.empty()) + orderLog += " -> "; + orderLog += p.name; + } + SPDLOG_INFO("[AudioEditor] FluidSynth: pack load order (last wins): {}", orderLog); + } + + // Render at the device rate so the mix needs no rate conversion. The device is + // opened at this CVar's value at startup, so reading it here matches the device. + double sampleRate = static_cast(CVarGetInteger(CVAR_AUDIO("OutputSampleRate"), 32000)); + SOH::FluidSynthConfig synthConfig; + synthConfig.sampleRate = sampleRate; + + // Mode-driven configuration. Authentic = richer modulators + console-era reverb. + // Enhanced = stock SF modulators + a subtle reverb that lets the SF's musical + // interpretation breathe through. The translator branches its NoteOn / CC11 + // routing on the same mode. + auto mode = static_cast(CVarGetInteger(CVAR_AUDIO("FluidSynthMode"), 0)); + synthConfig.linearVelocity = mode == SOH::SynthMode::Authentic; + + // FluidSynth defaults to 256 voices. The game's native scores stay well under + // that, but custom/modded songs can approach it, so bump to 512 for headroom. + // If it is ever hit, the log says so and you may hear dropped or stuck notes. + synthConfig.polyphony = 512; + + // FluidSynth's master output gain tracks the Master Volume slider, so the synth + // scales exactly as the native engine does (Master is applied per native note in + // audio_playback.c; the synth renders downstream of that). Loudness matching is a + // separate per-mode trim (ComputeSynthGlobalGain -> SetGlobalGain). The live + // slider stays in sync without a rebuild via AudioEditor_ApplySynthMasterVolume. + synthConfig.gain = SynthMasterGainFromCVar(); + auto synth = std::make_shared(synthConfig); + + // Stack every enabled pack's SF in discovery order. FluidSynth walks + // loaded sfonts in reverse on preset lookup, so the LAST loaded pack + // wins on (bank, program) collisions — matches the mod stack precedence. + size_t totalBytes = 0; + size_t loadedPacks = 0; + // One ": " entry per pack we couldn't load. Loading is + // best-effort: any pack that fails is skipped and the rest still load. + std::vector skipped; + std::unordered_map idToPackName; + for (const auto& pack : packs) { + auto bytes = ReadPackFile(pack, /*wantSf=*/true); + if (bytes.empty()) { + const char* reason = "soundfont file missing or unreadable"; + SPDLOG_ERROR("[AudioEditor] FluidSynth: skipping pack '{}' -- {}", pack.name, reason); + skipped.push_back(pack.name + ": " + reason); + continue; + } + int id = synth->AddSoundFontFromMemory(bytes.data(), bytes.size()); + if (id < 0) { + // -1 is FluidSynth's generic loader failure: corrupt/unsupported + // soundfont, or an .sf3 when the linked FluidSynth lacks libsndfile + // + Vorbis. We can't tell which apart, so name both possibilities. + const char* reason = "rejected by FluidSynth (corrupt soundfont, or " + ".sf3 without libsndfile/Vorbis support)"; + SPDLOG_ERROR("[AudioEditor] FluidSynth: skipping pack '{}' -- {}", pack.name, reason); + skipped.push_back(pack.name + ": " + reason); + continue; + } + idToPackName[id] = pack.name; + totalBytes += bytes.size(); + loadedPacks++; + SPDLOG_INFO("[AudioEditor] FluidSynth: loaded soundfont from pack '{}' ({} bytes, id={})", pack.name, + bytes.size(), id); + } + + if (loadedPacks == 0) { + SetStatus("No synth pack could be loaded.", true); + SetStatusWarnings(skipped); + return false; + } + + // Refresh the bypass-UI preset cache off the freshly-loaded sfonts. + // FluidSynth's iteration order is the SF's phdr order grouped by + // sfont; we keep that ordering for the cache so the UI rows track + // the load sequence (and therefore the priority order FluidSynth + // applies on preset lookup). + { + auto raw = synth->EnumerateLoadedPresets(); + sLoadedPresets.clear(); + sLoadedPresets.reserve(raw.size()); + for (auto& r : raw) { + LoadedPresetEntry e; + e.sfontId = r.sfontId; + auto it = idToPackName.find(r.sfontId); + e.packName = (it != idToPackName.end()) ? it->second : "(unknown)"; + e.bank = r.bank; + e.program = r.program; + e.name = std::move(r.name); + sLoadedPresets.push_back(std::move(e)); + } + // Unique (sfontId, bank) tuples preserving the load-order of sfonts. + // Inside one sfont, banks come out in iteration order — usually + // numerically ascending but we don't rely on that. + sBankSelectors.clear(); + for (const auto& p : sLoadedPresets) { + bool exists = false; + for (const auto& b : sBankSelectors) { + if (b.sfontId == p.sfontId && b.bank == p.bank) { + exists = true; + break; + } + } + if (!exists) { + sBankSelectors.push_back({ p.sfontId, p.packName, p.bank }); + } + } + SPDLOG_INFO("[AudioEditor] FluidSynth: {} presets across {} (sfont, bank) groups", sLoadedPresets.size(), + sBankSelectors.size()); + } + + // Refresh entry resolution against the new SF stack. Both inputs + // (entry sfontIds and the pack load order) changed here. + RefreshEntryResolution(packs); + + SetStatus(std::to_string(loadedPacks) + " pack" + (loadedPacks == 1 ? "" : "s") + " loaded (" + + std::to_string(totalBytes / (1024 * 1024)) + " MiB total, " + std::to_string(sLoadedPresets.size()) + + " presets)."); + // Some packs loaded, but not all -- surface the skipped ones too so a + // partial failure isn't silent on screen. + SetStatusWarnings(skipped); + + if (mode == SOH::SynthMode::Authentic) { + synth->SetReverbParams(0.65, 0.0, 1.0, 1.0); + } else { + synth->SetReverbParams(0.20, 0.5, 0.5, 0.30); + } + // Install the synth last. The audio thread (OTRAudio_Thread) picks it up via + // MidiSynthManager::GetActiveSynth() and mixes its Render() output into the + // resampled native bus -- no per-player mix source to register, so the synth + // survives backend switches (the manager owns it, not the AudioPlayer). + SOH::MidiSynthManager::Instance().SetSynth(synth); + SOH::MidiTranslator::Instance().SetSynthMode(mode); + SOH::MidiTranslator::Instance().SetGlobalGain(ComputeSynthGlobalGain(mode)); + SOH_MidiTranslator_Reset(); + return true; +} +} // namespace +#endif + +namespace { +// Enable/disable the Modern pipeline. The device already runs at the chosen rate +// with the audio thread resampling up, so the toggle only manages the synth. +void DisableModernAudioPipeline() { +#if ENABLE_FLUIDSYNTH + SOH::MidiSynthManager::Instance().SetSynth(nullptr); + SOH_MidiTranslator_Reset(); + SetStatus("Modern audio pipeline disabled."); +#endif +} + +bool EnableModernAudioPipeline() { +#if ENABLE_FLUIDSYNTH + // Full apply path: (optional) synth pack + status line. + return ApplyFluidSynthFromCVars(); +#else + return true; +#endif +} + +// Reconcile the CVar against live state once per draw, so a toggle on any tab (or +// the menu search) applies even when the original handler's tab isn't drawn. +void ReconcileModernAudioPipelineIfChanged() { + int now = CVarGetInteger(CVAR_AUDIO("ModernAudioPipeline"), 0); + static int sLast = now; + if (sLast == now) { + return; + } + if (now) { + if (!EnableModernAudioPipeline()) { + CVarSetInteger(CVAR_AUDIO("ModernAudioPipeline"), 0); + Ship::Context::GetRawInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + now = 0; + } + } else { + DisableModernAudioPipeline(); + } + sLast = now; +} +} // namespace + extern "C" { #include "z64save.h" extern SaveContext gSaveContext; @@ -38,6 +747,15 @@ static WidgetInfo voicePitch; static WidgetInfo randomAudioGenModes; static WidgetInfo lowerOctaves; +#if ENABLE_FLUIDSYNTH +static WidgetInfo fluidSynthEnabled; +// Global synth gain is per-mode: Authentic and Enhanced differ in loudness +// (reverb level + velocity curve), so each carries its own trim calibrated to +// the native engine. The active mode's widget is drawn in the FluidSynth tab. +static WidgetInfo fluidSynthGainAuthentic; +static WidgetInfo fluidSynthGainEnhanced; +#endif + namespace SohGui { extern std::shared_ptr mSohMenu; } @@ -223,6 +941,12 @@ void DrawPreviewButton(uint16_t sequenceId, std::string sfxKey, SeqType sequence .Tooltip("Stop Preview") .Color(THEME_COLOR))) { func_800F5C2C(); + // Abrupt stop: the engine swaps the sequence pointer without tracing each + // active note through Audio_NoteDisable, so FluidSynth voices left + // sounding become orphans in their release tail. Cycling many previews + // accumulates them faster than they drain and exhausts the voice pool, so + // force an immediate All Notes Off on every channel. + SOH_MidiTranslator_Reset(); CVarSetInteger(CVAR_AUDIO("Playing"), 0); } } else { @@ -233,6 +957,8 @@ void DrawPreviewButton(uint16_t sequenceId, std::string sfxKey, SeqType sequence .Color(THEME_COLOR))) { if (CVarGetInteger(CVAR_AUDIO("Playing"), 0) != 0) { func_800F5C2C(); + // Same orphan-voice flush as the stop button above. + SOH_MidiTranslator_Reset(); CVarSetInteger(CVAR_AUDIO("Playing"), 0); } else { if (sequenceType == SEQ_SFX || sequenceType == SEQ_VOICE) { @@ -542,6 +1268,12 @@ void AudioEditor::InitElement() { void AudioEditor::DrawElement() { AudioCollection::Instance->InitializeShufflePool(); + // Pick up Modern audio pipeline CVar transitions regardless of which + // tab is active right now — the toggle can land here from the Audio + // Options checkbox, from the FluidSynth tab's Enable button, or from + // the menu search bar. + ReconcileModernAudioPipelineIfChanged(); + UIWidgets::Separator(); if (UIWidgets::Button("Randomize All Groups", UIWidgets::ButtonOptions() @@ -613,12 +1345,1959 @@ void AudioEditor::DrawElement() { static_cast(ImGui::GetContentRegionAvail().x), THEME_COLOR); SohGui::mSohMenu->MenuDrawItem(lowerOctaves, static_cast(ImGui::GetContentRegionAvail().x), THEME_COLOR); + + // Master switch for the float audio pipeline. Always compiled; the + // float pipeline works without FluidSynth. When ENABLE_FLUIDSYNTH is + // on, a dedicated FluidSynth tab exposes pack selection and overrides. + // CVar transitions are picked up by ReconcileModernAudioPipelineIfChanged + // at the top of DrawElement, so a toggle here takes effect on any tab. + SohGui::mSohMenu->MenuDrawItem(fluidSynthEnabled, + static_cast(ImGui::GetContentRegionAvail().x), THEME_COLOR); + + // (FluidSynth pack selection, mode/volume, and the + // per-instrument override table live in the dedicated + // "FluidSynth" tab — see BeginTabItem("FluidSynth") below.) + } + ImGui::EndChild(); + ImGui::EndTable(); + ImGui::PopStyleVar(1); + ImGui::EndTabItem(); + } + +#if ENABLE_FLUIDSYNTH + if (ImGui::BeginTabItem("FluidSynth")) { + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, cellPadding); + ImGui::BeginTable("FluidSynthTab", 1, ImGuiTableFlags_SizingStretchSame); + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (ImGui::BeginChild("FluidSynthChild", ImVec2(0, -8))) { + + const bool pipelineOn = CVarGetInteger(CVAR_AUDIO("ModernAudioPipeline"), 0) != 0; + + if (!pipelineOn) { + // Pipeline is the gate for everything in this tab. Offer a + // one-click affordance so users who land here from the + // menu / docs don't have to flip back to Audio Options to + // enable it. The CVar transition is picked up by the + // top-of-DrawElement reconcile on the next frame. + ImGui::TextWrapped("The Modern audio pipeline is required to use FluidSynth. " + "Enable it to load a synth pack and route engine instruments " + "through it."); + ImGui::Spacing(); + if (ImGui::Button("Enable Modern Pipeline", ImVec2(220, 0))) { + CVarSetInteger(CVAR_AUDIO("ModernAudioPipeline"), 1); + Ship::Context::GetRawInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + } else { + // ── Status line ────────────────────────────────────── + // sLastStatus is written by ApplyFluidSynthFromCVars and + // friends so failure messages persist on screen instead + // of just landing in spdlog. + if (!sLastStatus.message.empty()) { + const ImVec4 green(0.40f, 0.85f, 0.45f, 1.0f); + const ImVec4 red(0.95f, 0.45f, 0.45f, 1.0f); + ImGui::TextColored(sLastStatus.isError ? red : green, "%s", sLastStatus.message.c_str()); + } + // Per-pack skip reasons from the last apply -- amber, one + // line each, wrapped so long reasons stay readable. + if (!sLastStatus.warnings.empty()) { + const ImVec4 amber(0.95f, 0.75f, 0.30f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Text, amber); + for (const auto& w : sLastStatus.warnings) { + ImGui::TextWrapped("Skipped %s", w.c_str()); + } + ImGui::PopStyleColor(); + } + ImGui::Separator(); + + // ── Synth packs ────────────────────────────────────── + // Discovered packs come from two sources (see + // EnumerateSynthPacks): mod-supplied via mounted .o2r + // archives, then loose .sf2/.sf3 files under + // /synth-packs/. The list is cached across + // frames; the Rescan button re-enumerates without a + // restart so a freshly-dropped file becomes visible. + static std::vector packs; + static bool packsListed = false; + if (!packsListed) { + packs = EnumerateSynthPacks(); + packsListed = true; + } + + // Count enabled packs for the summary header. We + // recompute the disabled set each frame — it's a cheap + // parse and keeps the count truthful even if a sibling + // tool edits the CVar between frames. + auto disabledSet = ParseDisabledPacksCSV(); + int enabledCount = 0; + for (const auto& e : packs) { + if (!disabledSet.count(PackKey(e))) + enabledCount++; + } + + ImGui::Text("Synth packs (%d enabled / %d discovered)", enabledCount, (int)packs.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Rescan##fluidsynthPacks")) { + packs = EnumerateSynthPacks(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Re-enumerate audio/synth/* across mounted .o2r archives\n" + "and /synth-packs/*.sf2 / *.sf3. Use after\n" + "dropping a new SF2/SF3 or mod without restarting."); + } + + if (packs.empty()) { + ImGui::TextDisabled("No synth packs discovered.\n" + "Drop an SF2/SF3 into /synth-packs/ (optionally\n" + "with a sibling .json mapping) or install a mod that\n" + "ships audio/synth//soundfont.sf2 (or .sf3)."); + } else { + // Bordered child so the list reads as a unit when + // many packs are discovered. Height clamps to a + // reasonable max so the rest of the tab stays + // visible; users can scroll inside. + const float rowH = ImGui::GetTextLineHeightWithSpacing(); + float listH = rowH * static_cast(std::min(packs.size(), 10)) + 12.0f; + if (ImGui::BeginChild("##synthPackList", ImVec2(420.0f, listH), ImGuiChildFlags_Border)) { + for (size_t i = 0; i < packs.size(); i++) { + const auto& e = packs[i]; + bool enabled = !disabledSet.count(PackKey(e)); + ImGui::PushID((int)i); + if (ImGui::Checkbox("##packCheck", &enabled)) { + SetPackDisabled(PackKey(e), !enabled); + Ship::Context::GetRawInstance() + ->GetWindow() + ->GetGui() + ->SaveConsoleVariablesNextFrame(); + ReapplyOverrideChain(); + ApplyFluidSynthFromCVars(); + } + ImGui::SameLine(); + const char* badge = (e.source == SynthPackEntry::Source::Archive) ? "[mod]" : "[loose]"; + ImGui::TextDisabled("%-7s", badge); + ImGui::SameLine(); + ImGui::TextUnformatted(e.name.c_str()); + if (ImGui::IsItemHovered()) { + if (e.mappingPath.empty()) { + ImGui::SetTooltip("%s\n(soundfont only - no mapping.json)", e.sfPath.c_str()); + } else { + ImGui::SetTooltip("%s\nmapping: %s", e.sfPath.c_str(), e.mappingPath.c_str()); + } + } + ImGui::PopID(); + } + } + ImGui::EndChild(); + ImGui::TextDisabled("Order = discovery order (mods first, then synth-packs/).\n" + "Later packs win on (bank, program) collisions."); + } + + if (enabledCount == 0) { + // Pipeline-only mode: native synthesis runs through + // the float path, but no SF substitution happens. + // The synth-side controls (mode radio, volume slider, + // bypass table) are still relevant only when at + // least one pack is active. + ImGui::Spacing(); + ImGui::TextDisabled("No synth packs enabled. The Modern audio pipeline is\n" + "active on its own (no instrument timbre change)."); + } else { + ImGui::Separator(); + + // ── Synth mode + volume ────────────────────────── + // Draw the gain slider for the mode that's actually + // active, so editing volume targets that mode's trim. + { + int gmode = CVarGetInteger(CVAR_AUDIO("FluidSynthMode"), 0); + SohGui::mSohMenu->MenuDrawItem( + gmode == 1 ? fluidSynthGainEnhanced : fluidSynthGainAuthentic, + static_cast(ImGui::GetContentRegionAvail().x), THEME_COLOR); + // Push live so dragging the slider updates loudness immediately + // (the translator no longer reads the CVar on the audio path). + SOH::MidiTranslator::Instance().SetGlobalGain( + ComputeSynthGlobalGain(static_cast(gmode))); + } + + { + int mode = CVarGetInteger(CVAR_AUDIO("FluidSynthMode"), 0); + ImGui::TextUnformatted("Synth mode:"); + ImGui::SameLine(); + if (ImGui::RadioButton("Authentic##synthMode", mode == 0)) { + CVarSetInteger(CVAR_AUDIO("FluidSynthMode"), 0); + Ship::Context::GetRawInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Console-style volume curve + console-era reverb.\n" + "Translator fixes NoteOn velocity at 100 and routes the\n" + "sqrt(velocity)-shaped value through CC11."); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Enhanced##synthMode", mode == 1)) { + CVarSetInteger(CVAR_AUDIO("FluidSynthMode"), 1); + Ship::Context::GetRawInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Stock SF default modulators + subtle reverb.\n" + "Translator sends the sqrt(velocity)-shaped value as\n" + "NoteOn velocity so the SF's own concave attenuation\n" + "modulator shapes dynamics. Good with musically-curated\n" + "banks (MuseScore, SC-55, orchestral packs)."); + } + + int nowMode = CVarGetInteger(CVAR_AUDIO("FluidSynthMode"), 0); + static int sLastMode = nowMode; + if (nowMode != sLastMode) { + ApplyFluidSynthFromCVars(); + sLastMode = nowMode; + } + } + + ImGui::Separator(); + + // ── Voice budget readout ───────────────────────── + // Snapshot the synth's voice state once per frame. When the + // active count approaches the polyphony limit, new NoteOns + // steal old voices — that's audible as "cuts" on dense + // songs. Colour-tier the text so the eye reads "near cap" + // without having to do the division. + { + auto activeSynth = SOH::MidiSynthManager::Instance().GetActiveSynth(); + uint32_t voiceActive = activeSynth ? activeSynth->GetActiveVoiceCount() : 0u; + uint32_t voiceLimit = activeSynth ? activeSynth->GetPolyphonyLimit() : 0u; + float ratio = voiceLimit > 0 ? float(voiceActive) / float(voiceLimit) : 0.0f; + ImVec4 colour(0.70f, 0.70f, 0.70f, 1.0f); // disabled grey baseline + if (ratio >= 0.80f) + colour = ImVec4(1.00f, 0.40f, 0.40f, 1.0f); // red + else if (ratio >= 0.60f) + colour = ImVec4(1.00f, 0.85f, 0.30f, 1.0f); // amber + if (voiceLimit > 0) { + ImGui::TextColored(colour, "FluidSynth voices: %u / %u", (unsigned)voiceActive, + (unsigned)voiceLimit); + } else { + ImGui::TextDisabled("FluidSynth voices: -"); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Active voices held by FluidSynth out of its polyphony\n" + "limit (default 256). When this approaches the limit,\n" + "new NoteOns steal old voices and dense passages cut.\n" + "If 'cuts' line up with values well below the limit,\n" + "the bottleneck is audio-thread CPU, not voices."); + } + + // ── Channel pool readout ───────────────────────── + // Distinct (fontId, instOrWave) pairs each claim one + // MIDI channel out of 64. The pool recycles idle pairs' + // channels on exhaustion, so sitting at 64 is normal; + // "reclaims" just counts how often a quiet pair handed + // its channel to a new one. + { + uint32_t chUsed = SOH::MidiTranslator::Instance().GetChannelsInUse(); + uint32_t chMax = SOH::MidiTranslator::kMaxMidiChannels; + uint32_t chReclaims = SOH::MidiTranslator::Instance().GetChannelReclaims(); + ImGui::TextDisabled("Synth channels: %u / %u (reclaims: %u)", (unsigned)chUsed, + (unsigned)chMax, (unsigned)chReclaims); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("MIDI channels held by synth-routed instrument pairs\n" + "out of the 64-channel pool. At the cap, the pool\n" + "recycles the channel of a pair that has gone quiet\n" + "for the new pair; 'reclaims' counts how often that\n" + "happened. Sitting at 64 is normal on a long session."); + } + } + // Throttled log so we can correlate user-reported cuts + // with the voice budget without spamming. Game-thread + // tick; coarse-grained on purpose. + static double sLastVoiceWarnTime = -10.0; + const double now = ImGui::GetTime(); + if (voiceLimit > 0 && ratio >= 0.80f && (now - sLastVoiceWarnTime) > 1.0) { + SPDLOG_WARN("[FluidSynth] high voice usage: {} / {} ({:.0f}%)", voiceActive, voiceLimit, + ratio * 100.0); + sLastVoiceWarnTime = now; + } + } + + // ── Per-instrument overrides ───────────────────── + SOH::DiscoveredPair pairs[SOH::MidiTranslator::kMaxDiscovered]; + int nPairs = SOH::MidiTranslator::Instance().DiscoveredSnapshot( + pairs, SOH::MidiTranslator::kMaxDiscovered); + + bool transSemis = CVarGetInteger(CVAR_AUDIO("FluidSynthTransSemitones"), 0) != 0; + if (ImGui::SmallButton("Clear list##bypassClear")) { + SOH::MidiTranslator::Instance().ClearDiscovered(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Reset all##bypassReset")) { + // Reset all + auto-save the cleared state so disk reflects + // the wipe. Without this, the next pack toggle would re-read + // the stale fluidsynth_overrides.json and undo the reset. + ResetToPackBaseline(); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Drop every personal override and restore the active pack's\n" + "defaults (Mode, Gain, Trans, Preset, effects). Discovered\n" + "list is left alone. The change is persisted to disk\n" + "automatically."); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Export pack...##bypassExport")) { + // Prefill the pack-name input with the most common pack + // among the currently active entries — matches the export + // gate (enabled + program), so a pack configured purely + // from its loaded mapping.json still prefills. + std::map tally; + int nEntries = SOH::MidiTranslator::Instance().GetEntryCount(); + for (int i = 0; i < nEntries; ++i) { + const auto& e = SOH::MidiTranslator::Instance().GetEntry(i); + if (!e.enabled || e.program < 0 || e.packName.empty()) + continue; + tally[e.packName]++; + } + std::string best; + int bestN = 0; + for (const auto& kv : tally) { + if (kv.second > bestN) { + best = kv.first; + bestN = kv.second; + } + } + std::strncpy(sExportPackName, best.c_str(), sizeof(sExportPackName) - 1); + sExportPackName[sizeof(sExportPackName) - 1] = '\0'; + sExportStatus[0] = '\0'; + ImGui::OpenPopup("Export pack mapping"); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Publish the pairs you picked for one pack. Only entries\n" + "currently enabled AND selected for the named pack are\n" + "exported; runtime fields (enabled/selected/sfontId) are\n" + "stripped. The pack name is written once in a 'pack_name'\n" + "header, not on every entry.\n\n" + "Two outputs:\n" + " .o2r - soundfont + mapping zipped into mods/.o2r\n" + " (the shareable mod; loads on next launch).\n" + " JSON only - mapping to synth-packs/.json, beside\n" + " your loose soundfont (picked up on Rescan)."); + } + // Export popup: pack-name input + entry-count preview + Export. + ImVec2 popupCenter = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(popupCenter, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (ImGui::BeginPopupModal("Export pack mapping", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("Publishes only entries that are currently enabled AND\n" + "selected for the named pack. The pack name (below) must\n" + "match the soundfont's name so the two stay paired."); + ImGui::Separator(); + + ImGui::SetNextItemWidth(280.0f); + ImGui::InputText("Pack name##exportPackName", sExportPackName, sizeof(sExportPackName)); + std::string filter = sExportPackName; + int previewN = SOH::MidiTranslator::Instance().CountExportableEntries(filter); + ImGui::Text("Entries to export: %d", previewN); + + bool canExport = previewN > 0 && !filter.empty(); + + // Primary: bundle soundfont + mapping into a shareable .o2r. + ImGui::BeginDisabled(!canExport); + if (ImGui::Button("Export .o2r##exportPackO2r", ImVec2(150, 0))) { + std::string msg; + bool ok = ExportPackO2r(filter, msg); + std::snprintf(sExportStatus, sizeof(sExportStatus), "%s%s", + ok ? "" : "FAILED: ", msg.c_str()); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Zip the loose soundfont + mapping into\n" + "mods/%s.o2r. Loads on next launch.", + filter.empty() ? "" : filter.c_str()); + } + ImGui::SameLine(); + // Secondary: mapping.json only, beside the loose soundfont. + if (ImGui::Button("JSON only##exportPackGo", ImVec2(120, 0))) { + std::string dest = Ship::Context::GetPathRelativeToAppDirectory( + (std::string(kLooseSynthPacksDirName) + "/" + filter + ".json").c_str(), + appShortName); + int n = SOH::MidiTranslator::Instance().ExportPackMapping(dest, filter); + if (n < 0) { + std::snprintf(sExportStatus, sizeof(sExportStatus), + "Export FAILED. See log for details."); + } else { + std::snprintf(sExportStatus, sizeof(sExportStatus), "Wrote %d entries to:\n%s", n, + dest.c_str()); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Write only the mapping to\n" + "synth-packs/%s.json (beside your loose soundfont).", + filter.empty() ? "" : filter.c_str()); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Close##exportPackClose", ImVec2(100, 0))) { + ImGui::CloseCurrentPopup(); + } + + if (sExportStatus[0]) { + ImGui::Separator(); + ImGui::TextWrapped("%s", sExportStatus); + } + ImGui::EndPopup(); + } + const float viewportH = ImGui::GetMainViewport()->Size.y; + float bypassTableHeight = viewportH * 0.65f; + if (bypassTableHeight < 400.0f) + bypassTableHeight = 400.0f; + if (bypassTableHeight > 900.0f) + bypassTableHeight = 900.0f; + // Column-id helper. Postincrement each named slot so + // adding/removing columns shifts the rest automatically. The + // saved variables (modeCol, presetCol, ...) are the only IDs + // referenced by per-row code below, so renumbering is a one-line + // edit here, not a hunt-and-replace. + uint8_t col = 0; + const uint8_t overrideCol = col++; + const uint8_t songCol = col++; + const uint8_t sampleCol = col++; + const uint8_t instCol = col++; + const uint8_t modeCol = col++; + const uint8_t gainCol = col++; + const uint8_t shiftCol = col++; + const uint8_t presetCol = col++; + // Adv: per-entry advanced popup (Reverb/Chorus/Cutoff/Q). Font and + // Source columns were folded into the Song / Preset tooltips; the four + // effect columns were folded into this one popup. + const uint8_t advCol = col++; + const uint8_t kColCount = col; + + if (ImGui::BeginTable("##bypassTable", kColCount, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | + ImGuiTableFlags_Resizable, + ImVec2(0.0f, bypassTableHeight))) { + // The Override column holds session-only widgets (Solo, + // temp volume). It sits first and its widgets are styled + // distinctly so the user reads "these don't get saved" at + // a glance. + ImGui::TableSetupColumn("Override", ImGuiTableColumnFlags_WidthFixed, 168.0f); + // Song stretches wider than Sample by default — the modder UX + // wants the song-family label readable at a glance, while the + // Sample column's editable rename mostly sits idle and only + // expands when typed into. + ImGui::TableSetupColumn("Song", ImGuiTableColumnFlags_WidthStretch, 1.6f); + ImGui::TableSetupColumn("Sample", ImGuiTableColumnFlags_WidthStretch, 1.0f); + ImGui::TableSetupColumn("Inst", ImGuiTableColumnFlags_WidthFixed, 96.0f); + ImGui::TableSetupColumn("Mode", ImGuiTableColumnFlags_WidthFixed, 150.0f); + ImGui::TableSetupColumn("Gain", ImGuiTableColumnFlags_WidthFixed, 130.0f); + ImGui::TableSetupColumn(transSemis ? "Shift (st)" : "Shift (oct)", + ImGuiTableColumnFlags_WidthFixed, 85.0f); + ImGui::TableSetupColumn("Preset", ImGuiTableColumnFlags_WidthFixed, 280.0f); + // Adv: a per-entry popup with the effect sends + filter + // (Reverb/Chorus/Cutoff/Q). Replaces the four narrow columns that + // pushed the table off-screen on smaller monitors. + ImGui::TableSetupColumn("Adv", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupScrollFreeze(0, 2); + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(overrideCol); + // Clear-overrides button — wipes all session-only state from the + // Override column across every row (solo set, native-row mutes, + // and every temp volume). Persisted overrides (Gain, Shift, + // Preset, effect CCs) are untouched — those have their own + // "Reset all" button above the table. + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.20f, 0.05f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.95f, 0.55f, 0.20f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1.00f, 0.65f, 0.30f, 1.0f)); + if (ImGui::SmallButton("Clear##overrideClear")) { + sSoloedPairs.clear(); + sExplicitMutedPairs.clear(); + sSoloedSlots.clear(); + sExplicitMutedSlots.clear(); + SOH::MidiTranslator::Instance().ClearAllTemporaryMutes(); + SOH::MidiTranslator::Instance().ClearAllTemporarySlotMutes(); + SOH::MidiTranslator::Instance().ClearAllTemporaryVolumes(); + } + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Clear every session-only override across the table:\n" + " - Soloed rows (all unsoloed)\n" + " - Mute toggles on Native rows\n" + " - Temp volume sliders on Synth rows\n" + "Persisted overrides (Gain, Shift, Preset, effect CCs) are\n" + "untouched - use 'Reset all' above the table for those."); + } + ImGui::TableSetColumnIndex(shiftCol); + { + bool transUnit = transSemis; + if (ImGui::Checkbox("Semitone##transUnit", &transUnit)) { + CVarSetInteger(CVAR_AUDIO("FluidSynthTransSemitones"), transUnit ? 1 : 0); + Ship::Context::GetRawInstance() + ->GetWindow() + ->GetGui() + ->SaveConsoleVariablesNextFrame(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Display the Shift column in semitones (+/-24 fine)\n" + "instead of octaves (+/-8 wide). Underlying value is\n" + "the same; column label and DragInt range switch."); + } + } + + ImGui::TableHeadersRow(); + + const ImU32 kSynthTint = IM_COL32(80, 160, 80, 80); + const ImU32 kNativeTint = IM_COL32(80, 120, 200, 80); + + // Per-entry "Adv" button + popup holding the effect sends + filter + // (Reverb/Chorus/Cutoff/Q). Shared by the melodic row, drum slots, + // and melodic ranges so every entry carries its own effects. The + // popup id is scoped by the active PushID (row/entry), so a fixed + // string is unique per row. -1 on a slider = "no override". + auto drawAdvPopup = [&](int idx) { + if (idx < 0) { + ImGui::TextDisabled("-"); + return; + } + if (ImGui::SmallButton("Adv##advbtn")) + ImGui::OpenPopup("advpop"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Per-entry effects: reverb, chorus, cutoff, Q."); + if (ImGui::BeginPopup("advpop")) { + const SOH::ConfigEntry& e = SOH::MidiTranslator::Instance().GetEntry(idx); + auto effRow = [&](const char* label, int8_t cur, + void (SOH::MidiTranslator::*setter)(int, int8_t), + const char* tip) { + ImGui::TextUnformatted(label); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", tip); + ImGui::SameLine(110.0f); + ImGui::PushID(label); + int v = cur; + ImGui::SetNextItemWidth(90.0f); + if (ImGui::DragInt("##v", &v, 0.5f, -1, 127, cur < 0 ? "off" : "%d")) { + v = std::clamp(v, -1, 127); + (SOH::MidiTranslator::Instance().*setter)(idx, (int8_t)v); + } + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", tip); + ImGui::PopID(); + }; + effRow("Reverb (CC91)", e.reverb, &SOH::MidiTranslator::SetEntryReverb, + "Reverb send. 0-127; drag below 0 to clear the override\n" + "and restore the channel default."); + effRow("Chorus (CC93)", e.chorus, &SOH::MidiTranslator::SetEntryChorus, + "Chorus send. 0-127; drag below 0 to clear."); + effRow("Cutoff (CC74)", e.cutoff, &SOH::MidiTranslator::SetEntryFilterCutoff, + "Low-pass cutoff. 64 = no shift from the SF default;\n" + "lower darkens, higher brightens. Drag below 0 to clear."); + effRow("Q (CC71)", e.q, &SOH::MidiTranslator::SetEntryFilterResonance, + "Filter resonance. 64 = no shift; higher emphasises the\n" + "cutoff. Drag below 0 to clear."); + ImGui::EndPopup(); + } + }; + (void)drawAdvPopup; + + // Per-entry octave/semitone Shift editor, in the Shift column unit + // (octaves or +/-24 semitones) chosen by the header toggle. Shared by + // the split-range rows so each range gets its own pitch shift; the + // unsplit melodic row keeps its own inline copy. idx < 0 -> "-". + auto drawShiftEditor = [&](int idx) { + if (idx < 0) { + ImGui::TextDisabled("-"); + return; + } + int transStored = SOH::MidiTranslator::Instance().GetEntry(idx).transpose; + int curOctaves = transStored / 12; + int curRemainder = transStored - curOctaves * 12; + int displayValue, displayMin, displayMax; + if (transSemis) { + displayValue = transStored; + displayMin = -24; + displayMax = 24; + } else { + displayValue = curOctaves; + displayMin = -8; + displayMax = 8; + } + const bool hasRemainder = (!transSemis && curRemainder != 0); + if (hasRemainder) + ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, IM_COL32(220, 150, 60, 70)); + char fmt[32]; + if (transSemis) + std::strcpy(fmt, "%+d st"); + else if (hasRemainder) + std::snprintf(fmt, sizeof fmt, "%%+d oct (%+d st)", curRemainder); + else + std::strcpy(fmt, "%+d oct"); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragInt("##rshift", &displayValue, 0.1f, displayMin, displayMax, fmt)) { + displayValue = std::clamp(displayValue, displayMin, displayMax); + int newSemis; + if (transSemis) + newSemis = displayValue; + else + newSemis = transStored + (displayValue - curOctaves) * 12; + newSemis = std::clamp(newSemis, -127, 127); + SOH::MidiTranslator::Instance().SetEntryTranspose(idx, (int8_t)newSemis); + } + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + // Right-click: re-apply the octave Shift derived from this range's + // engine sample tuning (auto-seeded on preset pick; this restores it). + const SOH::ConfigEntry& re = SOH::MidiTranslator::Instance().GetEntry(idx); + int rSuggest = + SOH::SuggestedTranspose(re.fontId, re.instOrWave, re.noteLow, re.noteHigh); + if (ImGui::BeginPopupContextItem("##rshiftctx")) { + if (ImGui::MenuItem(rSuggest % 12 == 0 ? "Apply suggested octave" + : "Apply suggested shift")) { + SOH::MidiTranslator::Instance().SetEntryTranspose(idx, (int8_t)rSuggest); + AutoSaveOverrides(); + } + ImGui::EndPopup(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Shift this range's notes (%+d st stored). Toggle Semitone\n" + "precision in the header for +/-24 st fine control.\n" + "Sample tuning suggests %+d st (right-click to apply).", + transStored, rSuggest); + }; + (void)drawShiftEditor; + + // Standardized Solo (S, brown/orange) + Mute (M, red) buttons. The + // warm/red palette signals "session-only, not saved". Used for both + // the pair-level sets and the per-drum-slot sets. + auto soloMuteToggle = [&](const char* sLbl, const char* mLbl, bool solo, bool mute, + const std::function& setSolo, + const std::function& setMute) { + ImGui::PushStyleColor(ImGuiCol_Button, solo ? ImVec4(0.85f, 0.45f, 0.10f, 1.0f) + : ImVec4(0.32f, 0.20f, 0.07f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.95f, 0.55f, 0.20f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1.00f, 0.65f, 0.30f, 1.0f)); + if (ImGui::SmallButton(sLbl)) + setSolo(!solo); + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Solo (session-only, not saved). Plays only soloed\n" + "rows; everything else is muted."); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, mute ? ImVec4(0.80f, 0.12f, 0.12f, 1.0f) + : ImVec4(0.32f, 0.10f, 0.08f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.90f, 0.30f, 0.20f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1.00f, 0.40f, 0.30f, 1.0f)); + if (ImGui::SmallButton(mLbl)) + setMute(!mute); + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Mute (session-only, not saved). Silences this row\n" + "on both the engine and synth paths."); + }; + auto drawPairSoloMute = [&](const std::pair& key) { + soloMuteToggle( + "S##psolo", "M##pmute", sSoloedPairs.count(key) > 0, + sExplicitMutedPairs.count(key) > 0, + [&](bool on) { + if (on) + sSoloedPairs.insert(key); + else + sSoloedPairs.erase(key); + }, + [&](bool on) { + if (on) + sExplicitMutedPairs.insert(key); + else + sExplicitMutedPairs.erase(key); + }); + }; + auto drawSlotSoloMute = [&](const std::tuple& key) { + soloMuteToggle( + "S##ssolo", "M##smute", sSoloedSlots.count(key) > 0, + sExplicitMutedSlots.count(key) > 0, + [&](bool on) { + if (on) + sSoloedSlots.insert(key); + else + sSoloedSlots.erase(key); + }, + [&](bool on) { + if (on) + sExplicitMutedSlots.insert(key); + else + sExplicitMutedSlots.erase(key); + }); + }; + (void)drawPairSoloMute; + (void)drawSlotSoloMute; + + // Effective-mute apply pass — runs once per frame BEFORE the + // per-row drawing so the audible state matches the UI even on + // the same frame the user clicks. The model: + // effective_mute(pair) = (anySolo && !inSolo) || inExplicitMute + // where anySolo = !sSoloedPairs.empty(), inSolo = membership in + // sSoloedPairs, inExplicitMute = membership in sExplicitMutedPairs. + { + // A soloed drum slot also counts toward the global solo + // state, so it mutes other pairs and sibling slots. + bool anySolo = !sSoloedPairs.empty() || !sSoloedSlots.empty(); + SOH::MidiTranslator::Instance().ClearAllTemporarySlotMutes(); + for (int i = 0; i < nPairs; i++) { + const auto& q = pairs[i]; + auto key = std::make_pair(q.fontId, q.instOrWave); + // A soloed sub-unit (drum slot OR melodic range) keeps its parent + // pair audible; scan regardless of pair kind so soloing a melodic + // range doesn't mute its own instrument. + bool pairHasSoloedSlot = false; + for (const auto& t : sSoloedSlots) + if (std::get<0>(t) == q.fontId && std::get<1>(t) == q.instOrWave) { + pairHasSoloedSlot = true; + break; + } + bool inSolo = sSoloedPairs.count(key) > 0 || pairHasSoloedSlot; + bool explicitMute = sExplicitMutedPairs.count(key) > 0; + bool effPair = (anySolo && !inSolo) || explicitMute; + SOH::MidiTranslator::Instance().SetTemporaryMute(q.fontId, q.instOrWave, effPair); + + // Per-slot / per-range mutes only matter when the channel itself + // isn't muted (the pair-mute short-circuits before per-slot). Drum + // slots and melodic sub-ranges are both keyed by noteLow; only the + // whole-pair full-range entry (and the Native marker) is excluded. + if (!effPair) { + std::vector idxs; + SOH::MidiTranslator::Instance().GetEntriesForPair(q.fontId, q.instOrWave, idxs); + for (int ei : idxs) { + const auto& ce = SOH::MidiTranslator::Instance().GetEntry(ei); + if (!ce.selected || (ce.noteLow == 0 && ce.noteHigh == 127)) + continue; + auto st = std::make_tuple(q.fontId, q.instOrWave, ce.noteLow); + // A soloed slot is more specific than a soloed pair: when the pair + // has any soloed slot, ONLY those slots play (slot-solo wins). A + // pair-solo lights up every slot only when no slot is soloed. + bool slotSolo = sSoloedSlots.count(st) > 0 || + (sSoloedPairs.count(key) > 0 && !pairHasSoloedSlot); + bool effSlot = (anySolo && !slotSolo) || sExplicitMutedSlots.count(st) > 0; + if (effSlot) + SOH::MidiTranslator::Instance().SetTemporarySlotMute( + q.fontId, q.instOrWave, ce.noteLow, true); + } + } + } + } + + for (int i = 0; i < nPairs; i++) { + const auto& p = pairs[i]; + ImGui::TableNextRow(); + // Push the row index BEFORE any widget so every "##" suffix in + // this row gets a unique ID (the Override and Song columns draw + // widgets first, and would otherwise collide across rows). + ImGui::PushID(i); + + // Activity tint: green if synth-active, blue if native-active, + // uncoloured otherwise. Split parents (drum/SFX, melodic ranges) + // aggregate this from their child entries below; this default + // serves the unsplit melodic row. + auto setRowTint = [&](bool anySynth, bool anyNative) { + if (anySynth) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, kSynthTint); + else if (anyNative) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, kNativeTint); + else + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, 0); + }; + setRowTint( + SOH::MidiTranslator::Instance().GetSynthActiveCount(p.fontId, p.instOrWave) > 0, + SOH::MidiTranslator::Instance().GetNativeActiveCount(p.fontId, p.instOrWave) > 0); + + // ── Drum / SFX pair: per-slot tree-row ─────────────── + // The engine routes all percussion through instOrWave 0 + // (SFX through 1); the `semitone` byte is a slot index, not a + // pitch. Parent row behaves like a melodic row (Solo/Mute, + // Native/Synth, a Kit dropdown); expanding reveals one child + // row per drum slot (per-slot Solo/Mute, Mode, Drum Sound). + // Diverts before the melodic body and continues. + bool forcedDrum = SOH::MidiTranslator::Instance().IsForcedDrum(p.fontId, p.instOrWave); + if (p.instOrWave == 0 || p.instOrWave == 1 || forcedDrum) { + auto pairKey = std::make_pair(p.fontId, p.instOrWave); + const char* fontName = SOH::GetFontName(p.fontId); + const SOH::ConfigEntry* dActive = + SOH::MidiTranslator::Instance().GetActiveEntry(p.fontId, p.instOrWave); + // The per-instrument Native/Synth state is the explicit + // master flag, NOT "any slot enabled" -- so per-slot edits + // never flip the instrument. A forced-drum pair has no + // separate master: the "Treat as drum" flag IS its Synth + // mode (clearing it reverts the pair to melodic). + bool channelSynth = forcedDrum ? true + : SOH::MidiTranslator::Instance().IsDrumChannelSynth( + p.fontId, p.instOrWave); + + // Parent tint aggregates the slots: green if any slot is + // synth-active, blue if any is native-active and none synth, + // uncoloured otherwise. (The per-pair counters would keep this + // permanently blue from the control slot that fires constantly + // with no entry to attribute to.) + { + bool anySynth = false, anyNative = false; + SOH::MidiTranslator::Instance().GetPairEntryActivity(p.fontId, p.instOrWave, + anySynth, anyNative); + setRowTint(anySynth, anyNative); + } + + // Override: expand arrow (first), then channel Solo + Mute. + ImGui::TableSetColumnIndex(overrideCol); + bool treeOpen = ImGui::TreeNodeEx( + "##drumtree", + ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_AllowItemOverlap, ""); + ImGui::SameLine(); + drawPairSoloMute(pairKey); + + // Song: font name. + ImGui::TableSetColumnIndex(songCol); + ImGui::TextUnformatted(fontName ? fontName : "(font)"); + + ImGui::TableSetColumnIndex(instCol); + if (forcedDrum) + ImGui::Text("%d (Drum)", (int)p.instOrWave); + else + ImGui::TextUnformatted(p.instOrWave == 0 ? "Drum" : "SFX"); + if (ImGui::IsItemHovered()) { + auto s = SOH::MidiTranslator::Instance().GetDebugStats(p.fontId, p.instOrWave); + const char* kind = + forcedDrum ? "forced-drum" : (p.instOrWave == 0 ? "drum" : "SFX"); + ImGui::SetTooltip("font %u, %s channel\nNoteOns %u (synth %u / native %u)", + (unsigned)p.fontId, kind, s.noteOns, s.routedSynth, + s.routedNative); + } + // Slots discovery lives in the Inst column (next to the + // channel), matching where melodic Split/L-M-H sit. + uint32_t hist[128]; + int distinct = SOH::MidiTranslator::Instance().GetDrumSlotHistogram( + p.fontId, p.instOrWave, hist); + char autoBtn[48]; + std::snprintf(autoBtn, sizeof autoBtn, "Slots (%d)##autosplit", distinct); + if (ImGui::SmallButton(autoBtn)) { + SOH::MidiTranslator::Instance().AutoSplitDrums(p.fontId, p.instOrWave); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Discover drum slots: create one entry per slot heard\n" + "(%d so far). Play the song first, then expand to map\n" + "each slot to a GM percussion sound.", + distinct); + + // Mode: for the intrinsic drum/SFX channels, Native / Synth + // for the whole channel. A forced-drum pair has no separate + // master (the flag IS Synth), so it shows a revert-to-melodic + // button instead. + ImGui::TableSetColumnIndex(modeCol); + if (forcedDrum) { + if (ImGui::SmallButton("Melodic##undrum")) { + SOH::MidiTranslator::Instance().SetForcedDrum(p.fontId, p.instOrWave, + false); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Stop treating this instrument as a drum\n" + "(revert to normal melodic routing)."); + } else { + if (ImGui::RadioButton("Native##dmode", !channelSynth)) { + SOH::MidiTranslator::Instance().SetDrumChannelSynth(p.fontId, p.instOrWave, + false); + AutoSaveOverrides(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Synth##dmode", channelSynth)) { + SOH::MidiTranslator::Instance().SetDrumChannelSynth(p.fontId, p.instOrWave, + true); + AutoSaveOverrides(); + } + // Dormant-slot warning: per-slot Synth entries do nothing + // while the channel master is Native (the channel plays + // native wholesale), so saved-but-silent slots aren't a + // mystery. + if (!channelSynth) { + int dormant = 0; + std::vector didx; + SOH::MidiTranslator::Instance().GetEntriesForPair(p.fontId, p.instOrWave, + didx); + for (int ei : didx) { + const auto& ce = SOH::MidiTranslator::Instance().GetEntry(ei); + if (ce.enabled && ce.selected && ce.noteLow == ce.noteHigh) + ++dormant; + } + if (dormant > 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "(%d dormant)", + dormant); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "%d slot(s) are set to Synth but the instrument is Native,\n" + "so they play the engine drum. Click Synth to hear them.", + dormant); + } + } + } + + // Preset: Kit dropdown (bank-128 presets). Mirrors the + // melodic Preset combo: a "None" entry sets the instrument to + // Native; picking a kit sets it to Synth. + ImGui::TableSetColumnIndex(presetCol); + ImGui::SetNextItemWidth(-1.0f); + std::string curKit = !channelSynth ? "None (native)" : "(pick a kit)"; + if (channelSynth && dActive && !dActive->packName.empty()) + curKit = dActive->presetName.empty() ? dActive->packName : dActive->presetName; + if (ImGui::BeginCombo("##drumkit", curKit.c_str())) { + // "None (native)" returns the whole instrument to native: + // the drum-channel master for 0/1, or un-forcing a + // forced-drum pair back to melodic. + if (ImGui::Selectable("None (native)", !channelSynth)) { + if (forcedDrum) + SOH::MidiTranslator::Instance().SetForcedDrum(p.fontId, p.instOrWave, + false); + else + SOH::MidiTranslator::Instance().SetDrumChannelSynth( + p.fontId, p.instOrWave, false); + AutoSaveOverrides(); + } + ImGui::Separator(); + for (const auto& lp : sLoadedPresets) { + if (lp.bank != 128) + continue; + char lbl[112]; + std::snprintf(lbl, sizeof lbl, "%s / %s##kit_%d_%d", lp.packName.c_str(), + lp.name.c_str(), lp.sfontId, lp.program); + bool sel = channelSynth && dActive && dActive->bank == 128 && + dActive->packName == lp.packName && + dActive->program == lp.program; + if (ImGui::Selectable(lbl, sel)) { + // Synth first (auto-splits slots if none exist), + // then apply the picked kit to those slots. + SOH::MidiTranslator::Instance().SetDrumChannelSynth(p.fontId, + p.instOrWave, true); + SOH::MidiTranslator::Instance().SetDrumKit( + p.fontId, p.instOrWave, lp.packName, (int16_t)lp.program, lp.name); + AutoSaveOverrides(); + } + if (sel) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + if (treeOpen) { + // Manual Add slot: configure a slot offline without replaying + // the song to discover it (the fix for forced-drum pairs that + // reload with no slots). Synth master only -- slots resolve + // only then. The slot index is transient, shared across rows. + if (channelSynth) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(instCol); + static int sAddSlot = 0; + ImGui::SetNextItemWidth(64.0f); + ImGui::DragInt("##addslotidx", &sAddSlot, 0.3f, 0, 127, "slot %d"); + ImGui::SameLine(); + if (ImGui::SmallButton("Add##addslot")) { + SOH::MidiTranslator::Instance().AddDrumSlot( + p.fontId, p.instOrWave, (uint8_t)std::clamp(sAddSlot, 0, 127)); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Add a drum slot by index without waiting to\n" + "discover it in-game. Created Native; pick a\n" + "Drum Sound to make it synth."); + } + + std::vector idxs; + SOH::MidiTranslator::Instance().GetEntriesForPair(p.fontId, p.instOrWave, idxs); + std::vector slots; + for (int ei : idxs) { + const auto& ce = SOH::MidiTranslator::Instance().GetEntry(ei); + // Drum splits are single-slot; skip full-range + // whole-pair entries so they don't all collapse onto + // slot 0. + if (ce.selected && ce.noteLow == ce.noteHigh) + slots.push_back(ei); + } + std::sort(slots.begin(), slots.end(), [](int a, int b) { + return SOH::MidiTranslator::Instance().GetEntry(a).noteLow < + SOH::MidiTranslator::Instance().GetEntry(b).noteLow; + }); + if (slots.empty()) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(instCol); + ImGui::TextDisabled("(no slots - set Synth or click Slots)"); + } + for (int ei : slots) { + const SOH::ConfigEntry& ce = SOH::MidiTranslator::Instance().GetEntry(ei); + ImGui::TableNextRow(); + ImGui::PushID(ei); + + // Per-slot activity tint (green synth / blue native). + if (SOH::MidiTranslator::Instance().GetEntrySynthActive(ei) > 0) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, kSynthTint); + else if (SOH::MidiTranslator::Instance().GetEntryNativeActive(ei) > 0) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, kNativeTint); + + // Override: per-slot Solo / Mute. + auto slotKey = std::make_tuple(p.fontId, p.instOrWave, ce.noteLow); + ImGui::TableSetColumnIndex(overrideCol); + drawSlotSoloMute(slotKey); + + ImGui::TableSetColumnIndex(instCol); + ImGui::Text("slot %d", (int)ce.noteLow); + + // Slot editing is gated by the instrument master mode: + // when the instrument is Native, the per-slot controls + // are read-only (Solo/Mute above stay live so a native + // drum can still be isolated). + ImGui::BeginDisabled(!channelSynth); + + // Native / Synth radio: the enabled flag IS the per-slot + // Native/Synth state. Native plays the engine drum; + // Synth plays the chosen GM sound. Does NOT touch the + // instrument mode. + ImGui::TableSetColumnIndex(modeCol); + if (ImGui::RadioButton("Native##smode", !ce.enabled)) { + SOH::MidiTranslator::Instance().SetEntryEnabled(ei, false); + AutoSaveOverrides(); + } + if (!channelSynth && + ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("Instrument is set to Native. Set the " + "instrument to Synth to edit slots."); + ImGui::SameLine(); + if (ImGui::RadioButton("Synth##smode", ce.enabled)) { + SOH::MidiTranslator::Instance().SetEntryRoute(ei, + SOH::EntryRoute::Synth); + SOH::MidiTranslator::Instance().SetEntryEnabled(ei, true); + AutoSaveOverrides(); + } + + // Sound dropdown, mirroring the melodic Preset combo: + // a filter box, a "None" entry that reverts to native + // (sets the radio to Native), and the GM percussion + // sounds (each one forces Synth). Disabled slot shows + // "None". + ImGui::TableSetColumnIndex(presetCol); + char preview[48]; + if (!ce.enabled) + std::snprintf(preview, sizeof preview, "None (native)"); + else if (ce.bank == 128) { + const char* nm = SOH::GmPercussionName(ce.fixedNote); + if (nm[0]) + std::snprintf(preview, sizeof preview, "%d %s", (int)ce.fixedNote, + nm); + else + std::snprintf(preview, sizeof preview, "note %d", + (int)ce.fixedNote); + } else + std::snprintf(preview, sizeof preview, "Pitch %d", + ce.fixedNote < 0 ? 60 : (int)ce.fixedNote); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::BeginCombo("##slotsound", preview, + ImGuiComboFlags_HeightLargest)) { + static char soundFilter[48] = ""; + if (ImGui::IsWindowAppearing()) { + soundFilter[0] = '\0'; + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##soundFilter", "Filter sounds", soundFilter, + sizeof soundFilter); + ImGui::Separator(); + auto containsCi = [](const char* hay, const char* needle) -> bool { + if (!needle || !*needle) + return true; + auto lc = [](char c) { + return (c >= 'A' && c <= 'Z') ? char(c + 32) : c; + }; + std::string h, n; + for (const char* q = hay; *q; ++q) + h += lc(*q); + for (const char* q = needle; *q; ++q) + n += lc(*q); + return h.find(n) != std::string::npos; + }; + bool filterActive = soundFilter[0] != '\0'; + if (!filterActive && ImGui::Selectable("None (native)", !ce.enabled)) { + SOH::MidiTranslator::Instance().SetEntryEnabled(ei, false); + AutoSaveOverrides(); + } + if (ce.bank == 128) { + for (int n = SOH::kGmPercussionLo; n <= SOH::kGmPercussionHi; ++n) { + const char* nm = SOH::GmPercussionName(n); + if (filterActive && !containsCi(nm, soundFilter)) + continue; + char lbl[48]; + std::snprintf(lbl, sizeof lbl, "%d %s##s%d", n, nm, n); + bool sel = (ce.enabled && ce.fixedNote == n); + if (ImGui::Selectable(lbl, sel)) { + SOH::MidiTranslator::Instance().SetEntryFixedNote( + ei, (int16_t)n); + SOH::MidiTranslator::Instance().SetEntryRoute( + ei, SOH::EntryRoute::Synth); + SOH::MidiTranslator::Instance().SetEntryEnabled(ei, true); + AutoSaveOverrides(); + } + if (sel) + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + // Per-slot Gain. + ImGui::TableSetColumnIndex(gainCol); + { + float g = ce.gain == 0.0f ? 1.0f : ce.gain; + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::DragFloat("##slotgain", &g, 0.01f, 0.0f, 4.0f, "%.2f")) + SOH::MidiTranslator::Instance().SetEntryGain(ei, + g < 0.0f ? 0.0f : g); + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + } + + // Tuned (non-128) Synth slot: an explicit pitch (Shift col). + if (ce.bank != 128 && ce.enabled) { + ImGui::TableSetColumnIndex(shiftCol); + ImGui::SetNextItemWidth(-1.0f); + int pitch = ce.fixedNote < 0 ? 60 : ce.fixedNote; + if (ImGui::DragInt("##pitch", &pitch, 1.0f, 0, 127)) + SOH::MidiTranslator::Instance().SetEntryFixedNote(ei, + (int16_t)pitch); + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + } + + // Per-slot advanced effects (Reverb/Chorus/Cutoff/Q). + ImGui::TableSetColumnIndex(advCol); + drawAdvPopup(ei); + + ImGui::EndDisabled(); // slot-edit gate (instrument mode) + ImGui::PopID(); + } + ImGui::TreePop(); + } + ImGui::PopID(); + continue; + } + + // ── Melodic note-range split: tree-row ─────────────── + // A melodic pair that has any sub-range entry (noteLow!=0 || + // noteHigh!=127) renders as a collapsible header + one child + // row per range (range editor + preset dropdown + Native/ + // Synth). Unsplit pairs fall through to the normal melodic row. + { + std::vector allIdx; + SOH::MidiTranslator::Instance().GetEntriesForPair(p.fontId, p.instOrWave, allIdx); + std::vector ranges; + bool isRangeSplit = false; + for (int ei : allIdx) { + const auto& ce = SOH::MidiTranslator::Instance().GetEntry(ei); + if (!ce.selected) + continue; + // The user Native marker (empty-pack route=Native) is a + // whole-pair native flag, not a split range -- skip it so it + // doesn't surface as a phantom range row. + if (ce.route == SOH::EntryRoute::Native && ce.packName.empty()) + continue; + ranges.push_back(ei); + if (ce.noteLow != 0 || ce.noteHigh != 127) + isRangeSplit = true; + } + if (isRangeSplit) { + std::sort(ranges.begin(), ranges.end(), [](int a, int b) { + return SOH::MidiTranslator::Instance().GetEntry(a).noteLow < + SOH::MidiTranslator::Instance().GetEntry(b).noteLow; + }); + auto pairKey = std::make_pair(p.fontId, p.instOrWave); + + // Parent tint aggregates the ranges: green if any range is + // synth-active, blue if any is native-active and none synth. + { + bool anySynth = false, anyNative = false; + SOH::MidiTranslator::Instance().GetPairEntryActivity(p.fontId, p.instOrWave, + anySynth, anyNative); + setRowTint(anySynth, anyNative); + } + + // Header: expand arrow (first), Solo/Mute, font, summary, + // Flatten. + ImGui::TableSetColumnIndex(overrideCol); + bool mTreeOpen = ImGui::TreeNodeEx( + "##melsplit", + ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_AllowItemOverlap, ""); + ImGui::SameLine(); + drawPairSoloMute(pairKey); + + ImGui::TableSetColumnIndex(songCol); + const char* fName = SOH::GetFontName(p.fontId); + ImGui::TextUnformatted(fName ? fName : "(font)"); + + ImGui::TableSetColumnIndex(instCol); + ImGui::Text("%d", (int)p.instOrWave); + + ImGui::TableSetColumnIndex(presetCol); + // Flag native (disabled) ranges so coverage that isn't synth is + // visible at a glance, not hidden one row deeper. + int nativeRanges = 0; + for (int ei : ranges) + if (!SOH::MidiTranslator::Instance().GetEntry(ei).enabled) + ++nativeRanges; + if (nativeRanges > 0) + ImGui::Text("%d ranges (%d native)", (int)ranges.size(), nativeRanges); + else + ImGui::Text("%d ranges", (int)ranges.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Flatten##melflat")) { + // Collapse back to unsplit: widen every range to the + // full 0..127 (so none register as a sub-range) and + // keep only the first enabled. The pair then renders + // as the normal melodic row again. + for (size_t k = 0; k < ranges.size(); ++k) { + SOH::MidiTranslator::Instance().SetEntryNoteRange(ranges[k], 0, 127); + if (k > 0) + SOH::MidiTranslator::Instance().SetEntryEnabled(ranges[k], false); + } + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Merge all ranges back into a single full-range entry."); + + if (mTreeOpen) { + for (size_t k = 0; k < ranges.size(); ++k) { + int ei = ranges[k]; + const SOH::ConfigEntry& ce = + SOH::MidiTranslator::Instance().GetEntry(ei); + ImGui::TableNextRow(); + ImGui::PushID(ei); + + if (SOH::MidiTranslator::Instance().GetEntrySynthActive(ei) > 0) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, kSynthTint); + else if (SOH::MidiTranslator::Instance().GetEntryNativeActive(ei) > 0) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, kNativeTint); + + // Per-range Solo / Mute (keyed by noteLow, like drum + // slots), so a single melodic range can be isolated. + ImGui::TableSetColumnIndex(overrideCol); + drawSlotSoloMute(std::make_tuple(p.fontId, p.instOrWave, ce.noteLow)); + + // Inst column: contiguous-range boundary editor. Ranges + // are kept adjacent and non-overlapping by construction: + // the first range's low is pinned to 0 and the last + // range's high to 127 (both grayed); the only editable + // value is the boundary between two ranges, and moving it + // shifts the neighbouring range's edge with it. So a + // range's low mirrors the previous range's high+1 and + // can't be set independently. + const bool isFirst = (k == 0); + const bool isLast = (k + 1 == ranges.size()); + const int prevIdx = isFirst ? -1 : ranges[k - 1]; + const int nextIdx = isLast ? -1 : ranges[k + 1]; + ImGui::TableSetColumnIndex(instCol); + // Low edge (== previous range's high + 1). Editing it moves + // the boundary shared with the previous range. + int lo = (int)ce.noteLow; + ImGui::BeginDisabled(isFirst); + ImGui::SetNextItemWidth(44.0f); + if (ImGui::DragInt("##rlo", &lo, 0.3f, 0, 127, "%d") && !isFirst) + SOH::MidiTranslator::Instance().SetSplitBoundary( + prevIdx, ei, (uint8_t)std::clamp(lo - 1, 0, 127)); + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + ImGui::EndDisabled(); + ImGui::SameLine(0.0f, 4.0f); + ImGui::TextUnformatted(".."); + ImGui::SameLine(0.0f, 4.0f); + // High edge (== next range's low - 1). Editing it moves the + // boundary shared with the next range. + int hi = (int)ce.noteHigh; + ImGui::BeginDisabled(isLast); + ImGui::SetNextItemWidth(44.0f); + if (ImGui::DragInt("##rhi", &hi, 0.3f, 0, 127, "%d") && !isLast) + SOH::MidiTranslator::Instance().SetSplitBoundary( + ei, nextIdx, (uint8_t)std::clamp(hi, 0, 127)); + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Engine-semitone range. Ranges stay adjacent: the\n" + "first starts at 0, the last ends at 127, and moving\n" + "a boundary shifts the neighbouring range with it."); + // Split/Merge under the range editor. + if (ImGui::SmallButton("Split##rsplit")) { + int mid = ((int)ce.noteLow + (int)ce.noteHigh + 1) / 2; + SOH::MidiTranslator::Instance().SplitEntry(ei, (uint8_t)mid); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Split this range in half."); + ImGui::SameLine(); + if (ImGui::SmallButton("Merge##rmerge")) { + SOH::MidiTranslator::Instance().MergeWithNext(ei); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Merge with the next (higher) range."); + + // Mode: Native / Synth (enabled flag). + ImGui::TableSetColumnIndex(modeCol); + if (ImGui::RadioButton("Native##rmode", !ce.enabled)) { + SOH::MidiTranslator::Instance().SetEntryEnabled(ei, false); + AutoSaveOverrides(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Synth##rmode", ce.enabled)) { + SOH::MidiTranslator::Instance().SetEntryEnabled(ei, true); + AutoSaveOverrides(); + } + + // Per-range Gain. + ImGui::TableSetColumnIndex(gainCol); + { + float g = ce.gain == 0.0f ? 1.0f : ce.gain; + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::DragFloat("##rgain", &g, 0.01f, 0.0f, 4.0f, "%.2f")) + SOH::MidiTranslator::Instance().SetEntryGain(ei, g < 0.0f ? 0.0f + : g); + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + } + + // Preset column: preset dropdown (filter + None). + ImGui::TableSetColumnIndex(presetCol); + char rprev[160]; + if (!ce.enabled) + std::snprintf(rprev, sizeof rprev, "None (native)"); + else if (ce.packName.empty()) + std::snprintf(rprev, sizeof rprev, "(none)"); + else + std::snprintf(rprev, sizeof rprev, "%s: %s", ce.packName.c_str(), + ce.presetName.c_str()); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::BeginCombo("##rpreset", rprev, + ImGuiComboFlags_HeightLargest)) { + static char rFilter[64] = ""; + if (ImGui::IsWindowAppearing()) { + rFilter[0] = '\0'; + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##rfilter", "Filter (preset or pack)", + rFilter, sizeof rFilter); + ImGui::Separator(); + auto ciHas = [](const std::string& hay, const char* n) { + if (!n || !*n) + return true; + auto lc = [](char c) { + return (c >= 'A' && c <= 'Z') ? char(c + 32) : c; + }; + std::string h, nn; + for (char c : hay) + h += lc(c); + for (const char* q = n; *q; ++q) + nn += lc(*q); + return h.find(nn) != std::string::npos; + }; + bool fActive = rFilter[0] != '\0'; + if (!fActive && ImGui::Selectable("None (native)", !ce.enabled)) { + SOH::MidiTranslator::Instance().SetEntryEnabled(ei, false); + AutoSaveOverrides(); + } + int lastSf = -2; + for (const auto& lp : sLoadedPresets) { + if (lp.bank == 128) + continue; // percussion kits aren't melodic + if (fActive && !ciHas(lp.name, rFilter) && + !ciHas(lp.packName, rFilter)) + continue; + if (lp.sfontId != lastSf) { + ImGui::Separator(); + ImGui::TextDisabled("%s", lp.packName.c_str()); + lastSf = lp.sfontId; + } + char it[200]; + std::snprintf(it, sizeof it, "B%d P%d: %s##%d:%d:%d", lp.bank, + lp.program, lp.name.c_str(), lp.sfontId, lp.bank, + lp.program); + bool sel = ce.enabled && ce.packName == lp.packName && + ce.bank == lp.bank && ce.program == lp.program; + if (ImGui::Selectable(it, sel)) { + SOH::MidiTranslator::Instance().SetEntryPreset( + ei, lp.packName, (int16_t)lp.program, (int16_t)lp.bank, + lp.name); + AutoSaveOverrides(); + } + if (sel) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + // Per-range pitch Shift (octaves / semitones), greyed + // when the range plays native (nothing to shift). + ImGui::TableSetColumnIndex(shiftCol); + ImGui::BeginDisabled(!ce.enabled); + drawShiftEditor(ei); + ImGui::EndDisabled(); + + // Per-range advanced effects (Reverb/Chorus/Cutoff/Q). + ImGui::TableSetColumnIndex(advCol); + drawAdvPopup(ei); + + ImGui::PopID(); + } + ImGui::TreePop(); + } + ImGui::PopID(); + continue; + } + } + + // Hoisted from below: needed by the Override column too. + // The Mode/Native vs Synth state comes straight from the + // entry resolution — active entry == nullptr means there's + // no enabled+resolvable entry for this pair, so native + // plays. The "Default:" hint label still references + // GetGmPreset since it's the only place to source a + // human-readable GM name for the "no override" preset + // placeholder. + SOH::GmPreset defaultGmForMode = SOH::GetGmPreset(p.fontId, p.instOrWave); + const SOH::ConfigEntry* activeEntry = + SOH::MidiTranslator::Instance().GetActiveEntry(p.fontId, p.instOrWave); + int activeIdx = + SOH::MidiTranslator::Instance().GetActiveEntryIdx(p.fontId, p.instOrWave); + // A whole-pair Native marker wins resolution (activeEntry non-null) + // but means the instrument plays native, so treat it as Native here. + bool effectiveIsNative = + (activeEntry == nullptr) || + (activeEntry->route == SOH::EntryRoute::Native && activeEntry->packName.empty()); + + // ── Override column (session-only Solo / Mute) ───────── + ImGui::TableSetColumnIndex(overrideCol); + auto pairKey = std::make_pair(p.fontId, p.instOrWave); + drawPairSoloMute(pairKey); + + ImGui::TableSetColumnIndex(songCol); + // Plain text — the font name is almost always accurate + // (per-font names come from runtime fontMap[]). The custom + // display_name lives in the Sample column instead, where + // it actually replaces something that's often empty or + // useless. See the InputTextWithHint below. + { + const char* font = SOH::GetFontName(p.fontId); + ImGui::TextUnformatted(font ? font : "(modded font)"); + } + + // Sample column: shows the SF sample names captured at load + // time (L / M / H range splits, deduped when they match). + // Modders can type their own label here; the auto name becomes + // the hint (shown only when the override is empty), so slots + // with no captured name still get a usable label. + // + // Keyed by (fontId, instOrWave), so a different fontId reusing + // the same instOrWave can carry a different name. The hint is + // built fresh each frame from the SF sample dataset; the + // override comes from the translator's mDisplayName map and is + // persisted with the rest of the row. + ImGui::TableSetColumnIndex(sampleCol); + { + // Build the "auto" sample-name label that doubles as + // the hint placeholder. + char autoBuf[256]; + autoBuf[0] = '\0'; + { + auto names = SOH::GetInstrumentSampleNames(p.fontId, p.instOrWave); + if (!names.empty()) { + const char* lowName = SOH::StripSamplePathPrefix(names.low); + const char* normalName = SOH::StripSamplePathPrefix(names.normal); + const char* highName = SOH::StripSamplePathPrefix(names.high); + auto isSame = [](const char* a, const char* b) { + return *a && *b && std::strcmp(a, b) == 0; + }; + const bool lEmpty = names.low.empty(); + const bool nEmpty = names.normal.empty(); + const bool hEmpty = names.high.empty(); + const bool allMatch = (lEmpty || nEmpty || isSame(lowName, normalName)) && + (nEmpty || hEmpty || isSame(normalName, highName)) && + (lEmpty || hEmpty || isSame(lowName, highName)); + if (allMatch) { + const char* shown = !nEmpty ? normalName : !lEmpty ? lowName : highName; + char tag[5] = "("; + int t = 1; + if (!lEmpty) + tag[t++] = 'L'; + if (!nEmpty) + tag[t++] = 'M'; + if (!hEmpty) + tag[t++] = 'H'; + tag[t++] = ')'; + tag[t] = '\0'; + std::snprintf(autoBuf, sizeof(autoBuf), "%s %s", shown, tag); + } else { + size_t pos = 0; + auto append = [&](const char* prefix, const char* val) { + if (!*val) + return; + int written = + std::snprintf(autoBuf + pos, sizeof(autoBuf) - pos, "%s%s:%s", + pos == 0 ? "" : " ", prefix, val); + if (written > 0) + pos += static_cast(written); + }; + append("L", SOH::StripSamplePathPrefix(names.low)); + append("M", SOH::StripSamplePathPrefix(names.normal)); + append("H", SOH::StripSamplePathPrefix(names.high)); + } + } + } + const char* hint = autoBuf[0] ? autoBuf : "(no sample name)"; + + const std::string current = + SOH::MidiTranslator::Instance().GetDisplayName(p.fontId, p.instOrWave); + char buf[80]; + std::strncpy(buf, current.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::InputTextWithHint("##displayName", hint, buf, sizeof(buf))) { + SOH::MidiTranslator::Instance().SetDisplayName(p.fontId, p.instOrWave, + std::string(buf)); + } + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + if (ImGui::IsItemHovered()) { + // Always show the engine's three per-range samples, + // whether or not a custom label is set. The custom + // label hides what the engine actually plays per + // pitch range; the tooltip is where the user reads + // back the raw mapping. + auto names = SOH::GetInstrumentSampleNames(p.fontId, p.instOrWave); + const char* lowName = SOH::StripSamplePathPrefix(names.low); + const char* normalName = SOH::StripSamplePathPrefix(names.normal); + const char* highName = SOH::StripSamplePathPrefix(names.high); + ImGui::SetTooltip("Hi: %s\nMid: %s\nLow: %s", + (highName && *highName) ? highName : "(empty)", + (normalName && *normalName) ? normalName : "(empty)", + (lowName && *lowName) ? lowName : "(empty)"); + } + } + + // (Font column removed; the raw fontId lives in the Inst-column + // hex display and the [DBG] popup.) + + ImGui::TableSetColumnIndex(instCol); + if (p.instOrWave == 0) { + ImGui::Text("0 (Drum)"); + } else if (p.instOrWave == 1) { + ImGui::Text("1 (SFX)"); + } else { + ImGui::Text("%d (0x%02X)", (int)p.instOrWave, (unsigned)(uint8_t)p.instOrWave); + } + // Debug stats on hover (the [DBG] popup, shown on mouseover). + if (ImGui::IsItemHovered()) { + auto s = SOH::MidiTranslator::Instance().GetDebugStats(p.fontId, p.instOrWave); + ImGui::SetTooltip("font %u, inst %d (0x%02X)\n" + "NoteOns %u (synth %u / native %u / mute %u)\n" + "last semitone %u (MIDI %u)", + (unsigned)p.fontId, (int)p.instOrWave, + (unsigned)(uint8_t)p.instOrWave, s.noteOns, s.routedSynth, + s.routedNative, s.routedMute, (unsigned)s.lastSemitone, + (unsigned)(s.lastSemitone + 21u)); + } + // Split entry points (own line under the inst id so they're + // visible and don't fight the narrow column). Manual "Split" + // bisects the current preset into two note ranges and works + // for any synth pair; "L/M/H" mirrors the engine's sample + // ranges and needs the captured boundaries (an .o2r regen). + if (activeEntry != nullptr && activeEntry->program >= 0) { + if (ImGui::SmallButton("Split##manualmel")) { + int mid = ((int)activeEntry->noteLow + (int)activeEntry->noteHigh + 1) / 2; + SOH::MidiTranslator::Instance().SplitEntry(activeIdx, (uint8_t)mid); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Split this instrument into two note ranges (at the\n" + "midpoint) so the low and high halves can use\n" + "different presets. Expand the row to edit them."); + // Split into N equal ranges in one click (the common "I want 4 + // ranges" case, instead of repeatedly bisecting the lowest). + ImGui::SameLine(); + static int sSplitN = 4; + ImGui::SetNextItemWidth(40.0f); + ImGui::DragInt("##splitn", &sSplitN, 0.1f, 2, 16, "%d"); + ImGui::SameLine(0.0f, 2.0f); + if (ImGui::SmallButton("Split N##manualmelN")) { + SOH::MidiTranslator::Instance().SplitEntryEven(activeIdx, + std::clamp(sSplitN, 2, 16)); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Split this instrument into N equal note ranges at once,\n" + "duplicating the current preset so each range can be\n" + "reassigned. Expand the row to edit them."); + auto rng = SOH::GetInstrumentSampleNames(p.fontId, p.instOrWave); + if (rng.hasRange && !(rng.rangeLo == 0 && rng.rangeHi >= 127)) { + ImGui::SameLine(); + if (ImGui::SmallButton("L/M/H##autosplitmel")) { + SOH::MidiTranslator::Instance().AutoSplitByEngineRanges(p.fontId, + p.instOrWave); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Split into the engine's low / normal / high sample\n" + "ranges (boundaries %d / %d), duplicating the current\n" + "preset so each range can be reassigned.", + (int)rng.rangeLo, (int)rng.rangeHi); + } + } + // "Treat as drum": route this melodic instrument through the + // drum path so each distinct note becomes a slot mapped to a GM + // percussion sound. For a melodic slot that's really percussion + // (e.g. a song using an instrument slot for a drum hit). Always + // available -- works even on a Native pair. + if (ImGui::SmallButton("As Drum##forcedrum")) { + SOH::MidiTranslator::Instance().SetForcedDrum(p.fontId, p.instOrWave, true); + AutoSaveOverrides(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Treat this instrument as a drum/percussion channel.\n" + "Each distinct note it plays becomes a slot you map to\n" + "a GM drum sound. Play the song first, then expand the\n" + "row and click Slots to discover the notes."); + ImGui::TableSetColumnIndex(modeCol); + // Native click disables every enabled entry for this pair + // (keeps their selected flag so ClickSynth can restore). + // Synth click re-enables: most-recently-enabled selected + // entry wins, falls back to any disabled-but-resolvable + // entry (mod-only-row case), last resort is a muted + // "None" placeholder. See MidiTranslator::ClickSynth. + if (ImGui::RadioButton("Native##bypass", effectiveIsNative)) { + SOH::MidiTranslator::Instance().ClickNative(p.fontId, p.instOrWave); + AutoSaveOverrides(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Synth##bypass", !effectiveIsNative)) { + SOH::MidiTranslator::Instance().ClickSynth(p.fontId, p.instOrWave); + AutoSaveOverrides(); + } + + // The per-entry editors (Gain, Shift, effect CCs) operate + // on the active entry; when there is none, nothing to + // edit so we grey them out. The Preset combo stays live + // so the user can pick a preset to leave Native mode. + ImGui::BeginDisabled(effectiveIsNative); + auto disabledTooltipIfNative = [&]() { + if (effectiveIsNative && + ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip("No active entry on this row. Gain / Shift /\n" + "effects edit the resolved entry's fields, so\n" + "they have nothing to operate on. Pick a preset\n" + "below or click Synth to restore the most\n" + "recent pick."); + } + }; + + ImGui::TableSetColumnIndex(gainCol); + float gainStored = activeEntry ? activeEntry->gain : 0.0f; + float gainShown = (gainStored == 0.0f) ? 1.0f : gainStored; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::SliderFloat("##gain", &gainShown, 0.0f, 4.0f, "%.2f")) { + if (activeIdx >= 0) { + SOH::MidiTranslator::Instance().SetEntryGain(activeIdx, gainShown); + } + } + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + disabledTooltipIfNative(); + + ImGui::TableSetColumnIndex(shiftCol); + int transStored = activeEntry ? activeEntry->transpose : 0; + + // Decompose the stored semitone offset into a coarse octave + // count + a leftover remainder in [-11..+11]. Using truncate- + // toward-zero (the language default for int /) keeps the + // remainder's sign matched to the input — e.g. -13 → -1 oct, + // -1 st (NOT -2 oct, +11 st). + int curOctaves = transStored / 12; + int curRemainder = transStored - curOctaves * 12; + int displayValue; + int displayMin, displayMax; + if (transSemis) { + displayValue = transStored; + displayMin = -24; + displayMax = 24; + } else { + displayValue = curOctaves; + displayMin = -8; + displayMax = 8; + } + + // Tint the cell orange when in octave mode AND there's a + // non-zero remainder — the displayed integer doesn't tell + // the full story so the user needs a visual cue (tooltip + // carries the exact remainder). Subtle alpha so it doesn't + // clash with the row's synth/native tint. + const bool hasRemainder = (!transSemis && curRemainder != 0); + if (hasRemainder) { + ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, IM_COL32(220, 150, 60, 70)); + } + + // Build a format string that includes the remainder hint in + // octave mode so the user sees "+2 oct (+5 st)" inline. + // In semitone mode just show the raw "+N st". + char fmt[32]; + if (transSemis) { + std::strcpy(fmt, "%+d st"); + } else if (hasRemainder) { + std::snprintf(fmt, sizeof(fmt), "%%+d oct (%+d st)", curRemainder); + } else { + std::strcpy(fmt, "%+d oct"); + } + + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragInt("##trans", &displayValue, 0.1f, displayMin, displayMax, fmt)) { + displayValue = std::clamp(displayValue, displayMin, displayMax); + int newSemis; + if (transSemis) { + // Direct semitone control — straight assignment. + newSemis = displayValue; + } else { + // Octave control — apply a delta in multiples of 12 + // and preserve the remainder so toggling between + // octave/semitone display doesn't silently drop the + // fine offset. + int deltaOctaves = displayValue - curOctaves; + newSemis = transStored + deltaOctaves * 12; + } + newSemis = std::clamp(newSemis, -127, 127); + if (activeIdx >= 0) { + SOH::MidiTranslator::Instance().SetEntryTranspose( + activeIdx, static_cast(newSemis)); + } + } + if (ImGui::IsItemDeactivatedAfterEdit()) + AutoSaveOverrides(); + // Right-click: re-apply the tuning-derived octave Shift (auto-seeded + // on preset pick). Only meaningful when there's an active entry. + int pairSuggest = + activeEntry ? SOH::SuggestedTranspose(p.fontId, p.instOrWave, activeEntry->noteLow, + activeEntry->noteHigh) + : 0; + if (activeIdx >= 0 && ImGui::BeginPopupContextItem("##transctx")) { + if (ImGui::MenuItem(pairSuggest % 12 == 0 ? "Apply suggested octave" + : "Apply suggested shift")) { + SOH::MidiTranslator::Instance().SetEntryTranspose(activeIdx, + (int8_t)pairSuggest); + AutoSaveOverrides(); + } + ImGui::EndPopup(); + } + if (ImGui::IsItemHovered()) { + if (transSemis) { + ImGui::SetTooltip( + "Shift this pair's notes by +/-24 semitones (fine).\n" + "Stored value: %+d st. 0 = no shift. Drums skip this column.\n" + "Sample tuning suggests %+d st (right-click to apply).", + (int)transStored, pairSuggest); + } else if (hasRemainder) { + ImGui::SetTooltip("Shift this pair's notes by +/-8 octaves (whole-scale).\n" + "Stored value: %+d st = %+d oct %+d st leftover.\n" + "The leftover semitone offset is preserved when you\n" + "drag here - octaves move by +/-12 around it. Enable\n" + "Semitone precision above to edit the leftover.\n" + "(Cell tinted to flag the leftover.)\n" + "Sample tuning suggests %+d st (right-click to apply).", + (int)transStored, curOctaves, curRemainder, pairSuggest); + } else { + ImGui::SetTooltip("Shift this pair's notes by +/-8 octaves (whole-scale).\n" + "Stored value: %+d st. 0 = no shift. Enable Semitone\n" + "precision above for +/-24 st fine control.\n" + "Sample tuning suggests %+d st (right-click to apply).", + (int)transStored, pairSuggest); + } + } + disabledTooltipIfNative(); + + // ── Source column ───────────────────────── + // Reflects the current resolution winner. When there's no + // active entry but there are selected entries (user picks + // whose source pack isn't loaded), surface the most + // recently enabled one as "[missing] [Pack]" so the user + // sees WHY the row went native. + // fallbackSaved: the most-recently-enabled selected entry for a + // currently-native row -- consumed by the Preset preview + + // tooltip below. + const SOH::ConfigEntry* fallbackSaved = nullptr; + if (!activeEntry) { + std::vector idxs; + SOH::MidiTranslator::Instance().GetEntriesForPair(p.fontId, p.instOrWave, idxs); + uint32_t bestSeq = 0; + for (int i2 : idxs) { + const SOH::ConfigEntry& e = SOH::MidiTranslator::Instance().GetEntry(i2); + if (!e.selected) + continue; + if (!fallbackSaved || e.lastEnabledSeq >= bestSeq) { + fallbackSaved = &e; + bestSeq = e.lastEnabledSeq; + } + } + } + + // Preset combo stays live even when the row is in Native + // mode — picking a preset is the user's path back to + // Synth (PickPreset disables every other entry for this + // pair, finds/creates the picked entry, enables+selects + // it). The Default item maps to ClickNative semantics. + ImGui::EndDisabled(); + + ImGui::TableSetColumnIndex(presetCol); + char defaultLabel[160]; + if (defaultGmForMode.program == SOH::kUnmapped) { + std::strcpy(defaultLabel, "Default: None"); + } else if (defaultGmForMode.bank == 128) { + std::snprintf(defaultLabel, sizeof(defaultLabel), "Default: drum kit %u, slot %u", + (unsigned)defaultGmForMode.program, + (unsigned)defaultGmForMode.drumNote); + } else { + std::snprintf(defaultLabel, sizeof(defaultLabel), "Default: %u: %s", + (unsigned)defaultGmForMode.program, + SOH::kGmProgramNames[defaultGmForMode.program]); + } + + char prgPreview[200]; + if (activeEntry) { + if (activeEntry->packName.empty()) { + std::strcpy(prgPreview, "(None)"); + } else if (activeEntry->bank == 128) { + // GM percussion falls back to native; the picked + // entry is preserved for per-slot routing. + std::snprintf(prgPreview, sizeof(prgPreview), "(drums -> native) P%d: %s", + activeEntry->program, activeEntry->presetName.c_str()); + } else { + std::snprintf(prgPreview, sizeof(prgPreview), "P%d: %s", activeEntry->program, + activeEntry->presetName.c_str()); + } + } else if (fallbackSaved && fallbackSaved->sfontId >= 0) { + // Pack still loaded; user just clicked Native. Show + // the saved preset name so the user remembers what + // Synth-click would restore. + std::snprintf(prgPreview, sizeof(prgPreview), "(off) P%d: %s", + fallbackSaved->program, fallbackSaved->presetName.c_str()); + } else if (fallbackSaved) { + std::snprintf(prgPreview, sizeof(prgPreview), "(B%d P%d not loaded)", + fallbackSaved->bank, fallbackSaved->program); + } else { + std::strcpy(prgPreview, defaultLabel); + } + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::BeginCombo("##prgCombo", prgPreview, ImGuiComboFlags_HeightLargest)) { + static char prgFilter[64] = ""; + if (ImGui::IsWindowAppearing()) { + prgFilter[0] = '\0'; + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##prgFilter", "Filter (preset or pack name)", prgFilter, + sizeof(prgFilter)); + ImGui::Separator(); + + const bool filterActive = prgFilter[0] != '\0'; + auto containsCi = [](const std::string& hay, const char* needle) -> bool { + if (!needle || !*needle) + return true; + const size_t nlen = std::strlen(needle); + if (nlen > hay.size()) + return false; + auto it = std::search(hay.begin(), hay.end(), needle, needle + nlen, + [](char a, char b) { + return std::tolower(static_cast(a)) == + std::tolower(static_cast(b)); + }); + return it != hay.end(); + }; + + if (!filterActive && ImGui::Selectable(defaultLabel, activeEntry == nullptr)) { + // "Default" disables every enabled entry for this pair + // (ClickNative). selected flags are preserved so the + // user can click Synth to restore. Picking a real preset + // below promotes it to selected. + SOH::MidiTranslator::Instance().ClickNative(p.fontId, p.instOrWave); + AutoSaveOverrides(); + } + + int lastSfont = -2; + int shown = 0; + for (const auto& lp : sLoadedPresets) { + if (filterActive && !containsCi(lp.name, prgFilter) && + !containsCi(lp.packName, prgFilter)) { + continue; + } + if (lp.sfontId != lastSfont) { + ImGui::Separator(); + ImGui::TextDisabled("%s", lp.packName.c_str()); + lastSfont = lp.sfontId; + } + char item[256]; + std::snprintf(item, sizeof(item), "B%d P%d: %s##%d:%d:%d", lp.bank, lp.program, + lp.name.c_str(), lp.sfontId, lp.bank, lp.program); + bool sel = activeEntry && activeEntry->packName == lp.packName && + activeEntry->bank == lp.bank && activeEntry->program == lp.program; + if (ImGui::Selectable(item, sel)) { + SOH::MidiTranslator::Instance().PickPreset( + p.fontId, p.instOrWave, lp.packName, static_cast(lp.program), + static_cast(lp.bank), lp.name); + AutoSaveOverrides(); + } + shown++; + } + if (sLoadedPresets.empty()) { + ImGui::Separator(); + ImGui::TextDisabled("(no SF presets loaded)"); + } else if (filterActive && shown == 0) { + ImGui::Separator(); + ImGui::TextDisabled("(no matches for \"%s\")", prgFilter); + } + ImGui::EndCombo(); + } + if (ImGui::IsItemHovered()) { + std::string tip = "Pick a preset from any loaded SF. Selecting creates\n" + "or reuses an entry for (font, inst, pack, program),\n" + "marks it selected, and disables any other enabled\n" + "entries for this pair. The pack + preset name are\n" + "persisted so the choice survives an SF stack change.\n" + "Default = disable enabled entries (row goes native)."; + if (activeEntry && activeEntry->bank == 128) { + tip += "\n\nGM percussion (bank 128) currently falls back\n" + "to native at play time. The picked preset is kept\n" + "in the JSON so a future per-drum-slot routing\n" + "path can use it."; + } + if (fallbackSaved) { + tip += "\n\nMost recent pick (not currently loaded):\n "; + tip += fallbackSaved->packName; + tip += " / "; + tip += fallbackSaved->presetName; + } + ImGui::SetTooltip("%s", tip.c_str()); + } + + // Advanced effects popup (Reverb/Chorus/Cutoff/Q) for the + // active entry. + ImGui::TableSetColumnIndex(advCol); + ImGui::BeginDisabled(effectiveIsNative); + drawAdvPopup(activeIdx); + ImGui::EndDisabled(); + ImGui::PopID(); + } + ImGui::EndTable(); + } + } + } } ImGui::EndChild(); ImGui::EndTable(); ImGui::PopStyleVar(1); ImGui::EndTabItem(); } +#endif // #if ENABLE_FLUIDSYNTH if (ImGui::BeginTabItem("Background Music")) { Draw_SfxTab("backgroundMusic", SEQ_BGM_WORLD, "Background Music"); @@ -824,6 +3503,20 @@ std::vector allTypes = { SEQ_INSTRUMENT, SEQ_SFX, SEQ_VOICE, SEQ_ENDING, }; +// Push the current Master Volume straight onto the live synth's master gain so +// dragging the slider scales synth output in lockstep with native -- no synth +// rebuild. No-op when no synth is installed (native path): GetActiveSynth() +// returns nullptr. Construction-time gain is set from the same CVar in +// ApplyFluidSynthFromCVars, so startup / pipeline-enable / backend-switch are +// already covered; this handles the slider moving while a synth is running. +void AudioEditor_ApplySynthMasterVolume() { +#if ENABLE_FLUIDSYNTH + if (auto synth = SOH::MidiSynthManager::Instance().GetActiveSynth()) { + synth->SetMasterGain(SynthMasterGainFromCVar()); + } +#endif +} + void AudioEditor_RandomizeAll() { for (auto type : allTypes) { RandomizeGroup(type); @@ -965,6 +3658,68 @@ void RegisterAudioWidgets() { "couple of octaves so they can still harmonize with the other notes of the " "sequence.")); SohGui::mSohMenu->AddSearchWidget({ lowerOctaves, "Enhancements", "Audio Editor", "Audio Options" }); + +#if ENABLE_FLUIDSYNTH + fluidSynthEnabled = { .name = "Modern audio pipeline (floating point)", .type = WidgetType::WIDGET_CVAR_CHECKBOX }; + fluidSynthEnabled.CVar(CVAR_AUDIO("ModernAudioPipeline")) + .Options(CheckboxOptions() + .Color(THEME_COLOR) + .Tooltip("Run the audio path in 32-bit float instead of 16-bit integer.\n" + "Removes the resampler's s16 quantisation on its own.\n" + "Required for FluidSynth synth packs - see the FluidSynth tab\n" + "for pack selection and per-instrument tuning.")); + SohGui::mSohMenu->AddSearchWidget({ fluidSynthEnabled, "Enhancements", "Audio Editor", "Audio Options" }); + + // Per-mode synth volume, shown to the user as a percentage relative to the + // native engine: 100% = matched to native. Each mode's real trim to reach + // native differs (reverb + curve) and is hidden behind a calibration constant + // in MidiTranslator, so the slider is a clean relative level defaulting to + // 100% for both modes -- the user never sees the raw per-mode values. + auto gainTip = "Synth loudness for this mode, relative to the native engine.\n" + "100% = matched to native."; + fluidSynthGainEnhanced = { .name = "Synth volume (Enhanced)", .type = WidgetType::WIDGET_CVAR_SLIDER_FLOAT }; + fluidSynthGainEnhanced.CVar(CVAR_AUDIO("FluidSynthGainEnhanced")) + .Options(FloatSliderOptions() + .Color(THEME_COLOR) + .IsPercentage() + .Min(0.0f) + .Max(2.0f) + .DefaultValue(1.0f) + .Size(ImVec2(300.0f, 0.0f)) + .Tooltip(gainTip)); + SohGui::mSohMenu->AddSearchWidget({ fluidSynthGainEnhanced, "Enhancements", "Audio Editor", "FluidSynth" }); + + fluidSynthGainAuthentic = { .name = "Synth volume (Authentic)", .type = WidgetType::WIDGET_CVAR_SLIDER_FLOAT }; + fluidSynthGainAuthentic.CVar(CVAR_AUDIO("FluidSynthGainAuthentic")) + .Options(FloatSliderOptions() + .Color(THEME_COLOR) + .IsPercentage() + .Min(0.0f) + .Max(2.0f) + .DefaultValue(1.0f) + .Size(ImVec2(300.0f, 0.0f)) + .Tooltip(gainTip)); + SohGui::mSohMenu->AddSearchWidget({ fluidSynthGainAuthentic, "Enhancements", "Audio Editor", "FluidSynth" }); + + // Auto-apply at launch: if the user had the Modern audio pipeline + // enabled in a previous session, switch the AudioPlayer into float + // mode now and stack every enabled synth pack so the engine doesn't + // keep playing on the s16 path until they open the AudioEditor. On + // hard failure clear the CVar so the checkbox stays honest. + if (CVarGetInteger(CVAR_AUDIO("ModernAudioPipeline"), 0)) { + if (!ApplyFluidSynthFromCVars()) { + CVarSetInteger(CVAR_AUDIO("ModernAudioPipeline"), 0); + Ship::Context::GetRawInstance()->GetWindow()->GetGui()->SaveConsoleVariablesNextFrame(); + } + } + + // Override layering — applied in order so later sources overlay earlier: + // 1. ResetAllOverrides — wipe to factory state (Auto / 1.0× / -1 / 0) + // 2. Each enabled pack's mapping.json (in discovery order) + // 3. User's fluidsynth_overrides.json — wins (missing file is the + // typical first-run state) + ReapplyOverrideChain(); +#endif } static RegisterMenuInitFunc menuInitFunc(RegisterAudioWidgets); diff --git a/soh/soh/Enhancements/audio/AudioEditor.h b/soh/soh/Enhancements/audio/AudioEditor.h index eb0a6856988..769ab107382 100644 --- a/soh/soh/Enhancements/audio/AudioEditor.h +++ b/soh/soh/Enhancements/audio/AudioEditor.h @@ -24,6 +24,10 @@ void AudioEditor_ResetAll(); void AudioEditor_ResetGroup(SeqType group); void AudioEditor_LockAll(); void AudioEditor_UnlockAll(); +// Pushes the current Master Volume CVar onto the running FluidSynth's master +// gain so the synth tracks the slider. A no-op when no synth is installed; call +// from the Master Volume slider's handler. +void AudioEditor_ApplySynthMasterVolume(); extern "C" { #endif diff --git a/soh/soh/Enhancements/audio/AudioResampler.cpp b/soh/soh/Enhancements/audio/AudioResampler.cpp new file mode 100644 index 00000000000..920c85be66f --- /dev/null +++ b/soh/soh/Enhancements/audio/AudioResampler.cpp @@ -0,0 +1,224 @@ +#include "soh/Enhancements/audio/AudioResampler.h" + +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace SOH { + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +AudioResampler::AudioResampler(int32_t inRate, int32_t outRate, int32_t numChannels) + : mInRate(inRate), mOutRate(outRate), mNumChannels(numChannels), mPhase(0) { + + int32_t g = GCD(inRate, outRate); + mP = outRate / g; /* upsample factor (e.g. 3 for 32k→48k) */ + mQ = inRate / g; /* downsample factor (e.g. 2 for 32k→48k) */ + mNumPhases = mP; + + BuildFilter(); + + /* History: kTapsPerPhase-1 past frames per channel, zero-initialized + * so the first output frames fade in cleanly from silence. */ + mHistory.assign((kTapsPerPhase - 1) * mNumChannels, 0.0f); +} + +// --------------------------------------------------------------------------- +// Filter construction — windowed-sinc lowpass, polyphase decomposition +// --------------------------------------------------------------------------- + +float AudioResampler::BesselI0(float x) { + /* Modified Bessel function of the first kind, order 0. + * Used for Kaiser window computation. + * Series expansion — converges well for x < 20 (beta up to ~14). */ + float sum = 1.0f; + float term = 1.0f; + float half_x = x * 0.5f; + for (int k = 1; k <= 30; k++) { + term *= (half_x / (float)k); + term *= (half_x / (float)k); + sum += term; + if (term < 1e-12f * sum) + break; + } + return sum; +} + +float AudioResampler::KaiserWindow(int n, int N, float beta) { + /* Kaiser window of length N+1, sample n in [0, N]. + * beta=6 gives ~60 dB stopband attenuation — good balance for audio. */ + float r = 2.0f * (float)n / (float)N - 1.0f; /* normalise to [-1, 1] */ + float inside = 1.0f - r * r; + if (inside < 0.0f) + inside = 0.0f; + return BesselI0(beta * sqrtf(inside)) / BesselI0(beta); +} + +float AudioResampler::Sinc(float x) { + if (fabsf(x) < 1e-8f) + return 1.0f; + float px = (float)M_PI * x; + return sinf(px) / px; +} + +void AudioResampler::BuildFilter() { + /* Total filter length: P * kTapsPerPhase taps. + * Cutoff at fc = 0.5 / max(P, Q) in normalised frequency (relative to + * the upsampled rate P*inRate = P*outRate/Q). For 32k→48k: P=3, Q=2, + * fc = 0.5/3 ≈ 0.167. */ + const int totalTaps = mNumPhases * kTapsPerPhase; + const float fc = 0.5f / (float)std::max(mP, mQ); + const float beta = 6.0f; + const int N = totalTaps - 1; + + std::vector h(totalTaps); + + /* Windowed sinc prototype filter */ + for (int i = 0; i < totalTaps; i++) { + float x = (float)i - (float)N * 0.5f; + h[i] = 2.0f * fc * Sinc(2.0f * fc * x) * KaiserWindow(i, N, beta); + } + + /* Polyphase decomposition: interleave into mNumPhases banks. + * Phase p contains taps h[p], h[p+P], h[p+2P], ... + * Normalise by P so energy is preserved after upsampling. */ + mCoeffs.resize(mNumPhases * kTapsPerPhase); + for (int phase = 0; phase < mNumPhases; phase++) { + for (int tap = 0; tap < kTapsPerPhase; tap++) { + mCoeffs[phase * kTapsPerPhase + tap] = h[phase + tap * mNumPhases] * (float)mP; + } + } +} + +// --------------------------------------------------------------------------- +// Reset +// --------------------------------------------------------------------------- + +void AudioResampler::Reset() { + std::fill(mHistory.begin(), mHistory.end(), 0.0f); + mPhase = 0; +} + +// --------------------------------------------------------------------------- +// MaxOutputFrames +// --------------------------------------------------------------------------- + +int32_t AudioResampler::MaxOutputFrames(int32_t inFrames) const { + /* ceil((inFrames * P) / Q) */ + return (int32_t)(((int64_t)inFrames * mP + mQ - 1) / mQ); +} + +// Polyphase rational resample: conceptually upsample by P (zero-stuff), lowpass, +// downsample by Q -- done without the zeros by walking phases and advancing the +// input only after Q phases. Per output: filter bank[mPhase], then mPhase += Q. + +int32_t AudioResampler::Process(const float* inBuf, int32_t inFrames, float* outBuf, int32_t maxOutFrames) { + const int histLen = kTapsPerPhase - 1; + const int ch = mNumChannels; + + /* Build a contiguous float window: history + new input. + * history holds the last (kTapsPerPhase-1) input frames as float. */ + const int windowFrames = histLen + inFrames; + std::vector window(windowFrames * ch); + + /* Copy history */ + for (int i = 0; i < histLen * ch; i++) { + window[i] = mHistory[i]; + } + + /* Append new input frames verbatim — samples are already float in the + * nominal [-1, 1] range so no conversion or normalisation is needed. */ + for (int i = 0; i < inFrames * ch; i++) { + window[histLen * ch + i] = inBuf[i]; + } + + /* Resample */ + int32_t outFrames = 0; + int32_t inPos = 0; /* current input frame position in window[] */ + int32_t phase = mPhase; + + while (inPos + kTapsPerPhase <= windowFrames && outFrames < maxOutFrames) { + const float* coeffs = &mCoeffs[phase * kTapsPerPhase]; + + for (int c = 0; c < ch; c++) { + float acc = 0.0f; + for (int tap = 0; tap < kTapsPerPhase; tap++) { + acc += window[(inPos + tap) * ch + c] * coeffs[tap]; + } + /* Pass through float as-is. Soft-clip happens upstream + * (OTRAudio_Thread's mix step); the polyphase filter is + * unity-gain so brief excursions slightly above 1.0 are fine. */ + outBuf[outFrames * ch + c] = acc; + } + outFrames++; + + /* Advance phase by Q; when phase wraps, consume one input frame */ + phase += mQ; + if (phase >= mP) { + phase -= mP; + inPos++; + } + } + + /* Save tail of window as new history */ + const int consumed = inPos; /* input frames consumed from window */ + const int remaining = histLen - (inFrames - consumed); + + if (inFrames >= histLen) { + /* Enough new input to fill history entirely from inBuf */ + for (int i = 0; i < histLen * ch; i++) { + mHistory[i] = window[(windowFrames - histLen) * ch + i]; + } + } else { + /* Partial update: shift old history and append new input */ + const int keep = histLen - inFrames; + for (int i = 0; i < keep * ch; i++) { + mHistory[i] = mHistory[inFrames * ch + i]; + } + for (int i = 0; i < inFrames * ch; i++) { + mHistory[keep * ch + i] = window[histLen * ch + i]; + } + } + + mPhase = phase; + return outFrames; +} + +// --------------------------------------------------------------------------- +// s16 overload: wraps the float core with conversions at the boundaries. +// --------------------------------------------------------------------------- + +int32_t AudioResampler::Process(const int16_t* inBuf, int32_t inFrames, int16_t* outBuf, int32_t maxOutFrames) { + const int ch = mNumChannels; + const int totalIn = inFrames * ch; + const int totalOut = maxOutFrames * ch; + + std::vector inF(totalIn); + std::vector outF(totalOut); + + constexpr float kS16ToFloat = 1.0f / 32768.0f; + for (int i = 0; i < totalIn; i++) { + inF[i] = static_cast(inBuf[i]) * kS16ToFloat; + } + + const int32_t outFrames = Process(inF.data(), inFrames, outF.data(), maxOutFrames); + + const int outSamples = outFrames * ch; + for (int i = 0; i < outSamples; i++) { + float v = outF[i] * 32767.0f; + if (v > 32767.0f) + v = 32767.0f; + if (v < -32768.0f) + v = -32768.0f; + outBuf[i] = static_cast(v); + } + return outFrames; +} + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/AudioResampler.h b/soh/soh/Enhancements/audio/AudioResampler.h new file mode 100644 index 00000000000..63e81b3c2f4 --- /dev/null +++ b/soh/soh/Enhancements/audio/AudioResampler.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include + +namespace SOH { + +/* + * AudioResampler — polyphase sinc resampler for integer ratios. + * + * Designed for the specific case of console audio upsampling from 32000 Hz + * to 48000 Hz (ratio 3/2 exact). Works for any integer ratio P/Q where + * P = outRate / gcd(outRate, inRate) and Q = inRate / gcd(outRate, inRate). + * + * Architecture: + * - Polyphase decomposition of a windowed-sinc lowpass filter. + * - Filter cutoff at min(inRate, outRate) / 2 to prevent aliasing. + * - Kaiser window (beta=6) for a good stopband attenuation (~60 dB). + * - For 32k→48k: P=3, Q=2, 8 taps per phase → 24 total filter coefficients. + * + * Usage: + * AudioResampler r(32000, 48000, numChannels); + * r.Process(inFloat, inFrames, outFloat, outFrames); + * + * Process() returns the number of output frames actually written. + * State (history samples) is preserved between calls for continuous streams. + * Samples are interleaved float in nominal [-1, 1] range; the polyphase + * filter is unity-gain so peaks slightly above 1.0 may pass through and + * should be soft-clipped by the caller (or before reaching the backend). + */ +class AudioResampler { + public: + AudioResampler(int32_t inRate, int32_t outRate, int32_t numChannels); + + /* Resample inFrames input frames into outBuf. + * Returns number of output frames written. + * outBuf must be large enough for ceil(inFrames * outRate / inRate) frames. + * + * Two overloads: + * - float in / float out is the canonical path used by the float audio + * pipeline. Samples are interleaved float in nominal [-1, 1]. + * - int16_t in / int16_t out is the legacy entry point preserved for + * libultraship consumers still on the s16 path. It converts at the + * boundaries and clamps the output to the s16 range; the inner DSP + * is identical (the filter coefficients live in float either way). */ + int32_t Process(const float* inBuf, int32_t inFrames, float* outBuf, int32_t maxOutFrames); + int32_t Process(const int16_t* inBuf, int32_t inFrames, int16_t* outBuf, int32_t maxOutFrames); + + /* Maximum output frames for a given number of input frames. */ + int32_t MaxOutputFrames(int32_t inFrames) const; + + /* Reset history (e.g. on stream discontinuity). */ + void Reset(); + + private: + int32_t mInRate; + int32_t mOutRate; + int32_t mNumChannels; + + /* Rational ratio P/Q after GCD reduction */ + int32_t mP; /* upsample factor */ + int32_t mQ; /* downsample factor */ + + /* Polyphase filter — mNumPhases phases × mTapsPerPhase taps */ + static constexpr int kTapsPerPhase = 8; + int32_t mNumPhases; /* = P */ + std::vector mCoeffs; /* [phase * kTapsPerPhase + tap] */ + + /* Current phase index in [0, P) */ + int32_t mPhase; + + /* History buffer: kTapsPerPhase-1 frames per channel for convolution state */ + std::vector mHistory; /* [(kTapsPerPhase-1) * numChannels] */ + + void BuildFilter(); + static float BesselI0(float x); + static float KaiserWindow(int n, int N, float beta); + static float Sinc(float x); + + static inline int32_t GCD(int32_t a, int32_t b) { + while (b) { + int32_t t = b; + b = a % b; + a = t; + } + return a; + } +}; + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/FluidSynth.cpp b/soh/soh/Enhancements/audio/FluidSynth.cpp new file mode 100644 index 00000000000..097b3e31bd9 --- /dev/null +++ b/soh/soh/Enhancements/audio/FluidSynth.cpp @@ -0,0 +1,557 @@ +#if ENABLE_FLUIDSYNTH +#include "soh/Enhancements/audio/FluidSynth.h" +#include +#include +#include +#include + +namespace SOH { + +namespace { +// Memory-backed SF loader: a custom loader keyed on the sentinel "mem://current" +// so path- and memory-based loads coexist. The open callback has no user-data slot, +// so the buffer passes through one static slot -- safe because AddSoundFontFromMemory +// runs only on the GUI thread under the synth mutex. + +struct MemoryInflight { + const uint8_t* data = nullptr; + size_t size = 0; +}; +static MemoryInflight sMemoryInflight; + +struct MemoryHandle { + const uint8_t* data; + size_t size; + size_t pos; +}; + +constexpr const char* kMemorySentinel = "mem://current"; + +void* MemoryOpen(const char* filename) { + if (filename == nullptr || std::strcmp(filename, kMemorySentinel) != 0) { + return nullptr; + } + if (sMemoryInflight.data == nullptr || sMemoryInflight.size == 0) { + return nullptr; + } + auto* h = new MemoryHandle{ sMemoryInflight.data, sMemoryInflight.size, 0 }; + // Single-shot: clear the slot so a stray repeat sfload can't replay. + sMemoryInflight = {}; + return h; +} + +int MemoryRead(void* buf, fluid_long_long_t count, void* handle) { + auto* h = static_cast(handle); + if (count < 0 || static_cast(count) > h->size - h->pos) { + return FLUID_FAILED; + } + std::memcpy(buf, h->data + h->pos, static_cast(count)); + h->pos += static_cast(count); + return FLUID_OK; +} + +int MemorySeek(void* handle, fluid_long_long_t offset, int origin) { + auto* h = static_cast(handle); + fluid_long_long_t newPos; + switch (origin) { + case SEEK_SET: + newPos = offset; + break; + case SEEK_CUR: + newPos = static_cast(h->pos) + offset; + break; + case SEEK_END: + newPos = static_cast(h->size) + offset; + break; + default: + return FLUID_FAILED; + } + if (newPos < 0 || static_cast(newPos) > h->size) { + return FLUID_FAILED; + } + h->pos = static_cast(newPos); + return FLUID_OK; +} + +fluid_long_long_t MemoryTell(void* handle) { + return static_cast(static_cast(handle)->pos); +} + +int MemoryClose(void* handle) { + delete static_cast(handle); + return FLUID_OK; +} + +// ---------------------------------------------------------------------- +// Route FluidSynth's own log output into the Ship logger. +// +// FluidSynth otherwise writes straight to stderr, bypassing our log sinks +// and level filtering. We forward each message at the matching spdlog level. +// ---------------------------------------------------------------------- +fluid_log_function_t FluidLogToShip(int level) { + switch (level) { + case FLUID_PANIC: + return [](int, const char* message, void*) { SPDLOG_CRITICAL("[FluidSynth] {}", message); }; + case FLUID_ERR: + return [](int, const char* message, void*) { SPDLOG_ERROR("[FluidSynth] {}", message); }; + case FLUID_WARN: + return [](int, const char* message, void*) { SPDLOG_WARN("[FluidSynth] {}", message); }; + case FLUID_INFO: + return [](int, const char* message, void*) { SPDLOG_INFO("[FluidSynth] {}", message); }; + case FLUID_DBG: + return [](int, const char* message, void*) { SPDLOG_DEBUG("[FluidSynth] {}", message); }; + default: + return [](int, const char* message, void*) { SPDLOG_INFO("[FluidSynth] {}", message); }; + } +} +} // namespace + +FluidSynth::FluidSynth(const FluidSynthConfig& config) + : mSampleRate(config.sampleRate), mLinearVelocity(config.linearVelocity) { + + static std::once_flag once; + std::call_once(once, [] { + // Not using any audio driver for fluidsynth, we pull samples via + // Render() ourselves. "file" is not used, but registering only it to + // avoid a warning when trying to load unavailable drivers such as SDL3. + const char* allowed_drivers[] = { "file", nullptr }; + fluid_audio_driver_register(allowed_drivers); + + // Redirect fluidsynth logs to SPDLOG at equivalent level + for (int level = 0; level < fluid_log_level::LAST_LOG_LEVEL; ++level) { + fluid_set_log_function(level, FluidLogToShip(level), nullptr); + } + }); + + mSettings = new_fluid_settings(); + // Sample rate MUST be set before new_fluid_synth — the synth reads it + // once at construction. + fluid_settings_setnum(mSettings, "synth.sample-rate", config.sampleRate); + // 64 channels = enough headroom for the per-pair channel allocator in + // MidiTranslator to give each (fontId, instOrWave) pair its own MIDI + // channel, so per-pair effect CCs (CC91/93/74/71) don't stomp each + // other. Must be a multiple of 16. + fluid_settings_setint(mSettings, "synth.midi-channels", kNumChannels); + // "none" = no internal audio driver; we pull samples via Render() ourselves. + // "file" is an offline render-to-disk mode and must NOT be used here. + fluid_settings_setstr(mSettings, "audio.driver", "none"); + + // Master gain. Stock FluidSynth is 0.2. + fluid_settings_setnum(mSettings, "synth.gain", config.gain); + + // Polyphony (max simultaneous voices). Stock FluidSynth is 256; the integrating + // game sizes this for its workload (see FluidSynthConfig::polyphony). Undersizing + // drops notes. FluidSynth frees each voice when its sample/envelope completes (no + // leak) and idle voices are cheap, so a generous ceiling is fine -- e.g. when a + // game layers a full melodic mapping plus voice-holding one-shot percussion. + fluid_settings_setint(mSettings, "synth.polyphony", config.polyphony); + + mSynth = new_fluid_synth(mSettings); + if (!mSynth) { + SPDLOG_ERROR("[FluidSynth] Failed to create synth"); + return; + } + + // Verify the sample rate FluidSynth actually locked in. + double actualRate = 0.0; + fluid_settings_getnum(mSettings, "synth.sample-rate", &actualRate); + SPDLOG_INFO("[FluidSynth] Synth created. Requested sample rate={} actual={} linearVelocity={} " + "polyphony={} gain={}", + config.sampleRate, actualRate, mLinearVelocity, config.polyphony, config.gain); + + if (mLinearVelocity) { + InstallLinearVelocityModulators(); + } + + // Mode-neutral: make the host's Cutoff/Q sliders (CC74/CC71) audible. Stock + // FluidSynth has no default modulator for either, so without this they do nothing. + InstallFilterCcModulators(); + + // Register the memory-backed sound-font loader alongside the default + // filesystem loader. Loaders are tried in addition order: default + // catches real filesystem paths, ours catches the mem:// sentinel. + // FluidSynth takes ownership of the loader and frees it via + // delete_fluid_synth. + fluid_sfloader_t* memLoader = new_fluid_defsfloader(mSettings); + if (memLoader) { + fluid_sfloader_set_callbacks(memLoader, MemoryOpen, MemoryRead, MemorySeek, MemoryTell, MemoryClose); + fluid_synth_add_sfloader(mSynth, memLoader); + } else { + SPDLOG_WARN("[FluidSynth] Memory sound-font loader unavailable; " + "LoadSoundFontFromMemory will fall back to default loader"); + } +} + +void FluidSynth::InstallLinearVelocityModulators() { + // Adapted from ANMP (GPL-2, github.com/derselbst/ANMP): halve the SF default + // velocity/CC7/CC11 -> attenuation modulators (960 -> 480 cB) to lift quiet + // voices without flattening dynamics. remove+add_default_mod (not add OVERWRITE, + // which needs an exact source-flag match); run after new_fluid_synth but before + // any load so instrument-level modulators still layer on top. + + fluid_mod_t* mod = new_fluid_mod(); + if (!mod) { + SPDLOG_ERROR("[FluidSynth] new_fluid_mod() failed; velocity modulators disabled"); + return; + } + + constexpr int kHalfAttenuationCentibels = 480; // = 960 / 2 + + fluid_mod_set_source2(mod, FLUID_MOD_NONE, 0); + fluid_mod_set_dest(mod, GEN_ATTENUATION); + fluid_mod_set_amount(mod, kHalfAttenuationCentibels); + + // 1. NoteOn velocity → initial attenuation (concave, halved). + fluid_mod_set_source1(mod, FLUID_MOD_VELOCITY, + FLUID_MOD_GC | FLUID_MOD_CONCAVE | FLUID_MOD_UNIPOLAR | FLUID_MOD_NEGATIVE); + fluid_synth_remove_default_mod(mSynth, mod); + fluid_synth_add_default_mod(mSynth, mod, FLUID_SYNTH_OVERWRITE); + + // 2. CC7 (channel volume) → initial attenuation (concave, halved). + fluid_mod_set_source1(mod, 7, FLUID_MOD_CC | FLUID_MOD_CONCAVE | FLUID_MOD_UNIPOLAR | FLUID_MOD_NEGATIVE); + fluid_synth_remove_default_mod(mSynth, mod); + fluid_synth_add_default_mod(mSynth, mod, FLUID_SYNTH_OVERWRITE); + + // 3. CC11 (expression) → initial attenuation (concave, halved). + fluid_mod_set_source1(mod, 11, FLUID_MOD_CC | FLUID_MOD_CONCAVE | FLUID_MOD_UNIPOLAR | FLUID_MOD_NEGATIVE); + fluid_synth_remove_default_mod(mSynth, mod); + fluid_synth_add_default_mod(mSynth, mod, FLUID_SYNTH_OVERWRITE); + + delete_fluid_mod(mod); + + SPDLOG_INFO("[FluidSynth] velocity modulators installed (vel/CC7/CC11 concave x 0.5)"); +} + +void FluidSynth::InstallFilterCcModulators() { + // GM2/GS map CC74 (Brightness) -> filter cutoff and CC71 (Harmonic Content) -> + // filter resonance, but neither is an SF2.01 default modulator, so stock + // FluidSynth ignores both and the host's Cutoff/Q sliders are inert. Add them + // as default modulators. BIPOLAR, centered at CC 64: a value of 64 contributes + // nothing (matches the host's neutral default), <64 darkens/relaxes, >64 + // brightens/sharpens. ADD (not OVERWRITE): these sources have no default mod, and + // instrument-level SF mods still layer on top. Amounts are deliberately moderate + // -- tune by ear. + fluid_mod_t* mod = new_fluid_mod(); + if (!mod) { + SPDLOG_ERROR("[FluidSynth] new_fluid_mod() failed; filter CC modulators disabled"); + return; + } + fluid_mod_set_source2(mod, FLUID_MOD_NONE, 0); + + // CC74 -> filter cutoff. +/-4800 cents (+/-4 octaves) full bipolar swing. + fluid_mod_set_source1(mod, 74, FLUID_MOD_CC | FLUID_MOD_LINEAR | FLUID_MOD_BIPOLAR | FLUID_MOD_POSITIVE); + fluid_mod_set_dest(mod, GEN_FILTERFC); + fluid_mod_set_amount(mod, 4800); + fluid_synth_add_default_mod(mSynth, mod, FLUID_SYNTH_ADD); + + // CC71 -> filter resonance (Q). +/-120 cB (+/-12 dB) full bipolar swing. + fluid_mod_set_source1(mod, 71, FLUID_MOD_CC | FLUID_MOD_LINEAR | FLUID_MOD_BIPOLAR | FLUID_MOD_POSITIVE); + fluid_mod_set_dest(mod, GEN_FILTERQ); + fluid_mod_set_amount(mod, 120); + fluid_synth_add_default_mod(mSynth, mod, FLUID_SYNTH_ADD); + + delete_fluid_mod(mod); + SPDLOG_INFO("[FluidSynth] filter CC modulators installed (CC74->cutoff, CC71->Q)"); +} + +FluidSynth::~FluidSynth() { + if (mSynth) + delete_fluid_synth(mSynth); + if (mSettings) + delete_fluid_settings(mSettings); +} + +void FluidSynth::ClearSoundFonts() { + std::lock_guard lock(mSynthMutex); + if (!mSynth) { + mSfontIds.clear(); + mLoadedBuffers.clear(); + return; + } + for (int id : mSfontIds) { + if (id != FLUID_FAILED) + fluid_synth_sfunload(mSynth, id, /*reset_presets=*/1); + } + mSfontIds.clear(); + mLoadedBuffers.clear(); + mLoadedBuffers.shrink_to_fit(); + // reset_presets above cleared channel state inside the synth, so the + // RPN-0 (pitch bend range) push needs to repeat on the next NoteOn. + for (bool& inited : mChannelInited) + inited = false; +} + +int FluidSynth::AddSoundFont(const std::string& path) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return FLUID_FAILED; + // reset_presets only on the FIRST sfont — for subsequent loads we + // want preset assignments on existing channels left alone so a + // stacked pack doesn't blow away the prior pack's program selection. + int resetPresets = mSfontIds.empty() ? 1 : 0; + int id = fluid_synth_sfload(mSynth, path.c_str(), resetPresets); + if (id == FLUID_FAILED) { + SPDLOG_ERROR("[FluidSynth] Failed to load SF: {}", path); + return FLUID_FAILED; + } + SPDLOG_INFO("[FluidSynth] Loaded SF: {} (id={})", path, id); + mSfontIds.push_back(id); + mLoadedBuffers.emplace_back(); // empty — filesystem load owns its own data + if (resetPresets) { + for (bool& inited : mChannelInited) + inited = false; + } + return id; +} + +int FluidSynth::AddSoundFontFromMemory(const uint8_t* data, size_t size) { + std::lock_guard lock(mSynthMutex); + if (!mSynth || data == nullptr || size == 0) + return FLUID_FAILED; + + // Pre-reserve the slot so we can hand its address through the static + // sMemoryInflight pointer for the duration of sfload. Vector growth + // is fine here because the SF buffer lives in the vector element, + // which is itself a vector (small, by-value relocations + // don't invalidate the underlying heap-allocated data). + mLoadedBuffers.emplace_back(data, data + size); + auto& buf = mLoadedBuffers.back(); + sMemoryInflight = { buf.data(), buf.size() }; + int resetPresets = mSfontIds.empty() ? 1 : 0; + int id = fluid_synth_sfload(mSynth, kMemorySentinel, resetPresets); + sMemoryInflight = {}; + if (id == FLUID_FAILED) { + SPDLOG_ERROR("[FluidSynth] Failed to load SF from memory ({} bytes)", size); + mLoadedBuffers.pop_back(); + return FLUID_FAILED; + } + SPDLOG_INFO("[FluidSynth] Loaded SF from memory ({} bytes, id={})", size, id); + mSfontIds.push_back(id); + if (resetPresets) { + for (bool& inited : mChannelInited) + inited = false; + } + return id; +} + +std::vector FluidSynth::GetLoadedSfontIds() { + std::lock_guard lock(mSynthMutex); + return mSfontIds; +} + +std::vector FluidSynth::EnumerateLoadedPresets() { + std::lock_guard lock(mSynthMutex); + std::vector result; + if (!mSynth) + return result; + for (int id : mSfontIds) { + if (id == FLUID_FAILED) + continue; + fluid_sfont_t* sfont = fluid_synth_get_sfont_by_id(mSynth, id); + if (!sfont) + continue; + fluid_sfont_iteration_start(sfont); + while (fluid_preset_t* preset = fluid_sfont_iteration_next(sfont)) { + LoadedPreset p; + p.sfontId = id; + p.bank = fluid_preset_get_banknum(preset); + p.program = fluid_preset_get_num(preset); + const char* nm = fluid_preset_get_name(preset); + p.name = nm ? nm : ""; + result.push_back(std::move(p)); + } + } + return result; +} + +void FluidSynth::LoadSoundFont(const std::string& path) { + ClearSoundFonts(); + AddSoundFont(path); +} + +void FluidSynth::LoadSoundFontFromMemory(const uint8_t* data, size_t size) { + ClearSoundFonts(); + AddSoundFontFromMemory(data, size); +} + +void FluidSynth::InitChannel(uint8_t channel) { + if (mChannelInited[channel]) + return; + mChannelInited[channel] = true; + + int ch = static_cast(channel); + + // Set pitch-bend range via the dedicated API. The MIDI-spec equivalent (CC + // 101/100/6/38 RPN sequence) has subtle behavior differences across FluidSynth + // versions; the direct semitone setter avoids the ambiguity. + fluid_synth_pitch_wheel_sens(mSynth, ch, static_cast(kPitchBendRangeSemitones)); + + // fluid_synth_set_gen() applies an additive (NRPN-style) offset on top of the + // SF zone value rather than overriding it, and the absolute sibling set_gen2() + // isn't in the 2.5.2 public API. So baked LFO-to-pitch can't be silenced + // channel-wide; it's patched per-voice on NoteOn (common case) or at SF load + // time. See NoteOn(). +} + +void FluidSynth::NoteOn(uint8_t channel, uint8_t note, uint8_t velocity) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return; + InitChannel(channel); + int result = fluid_synth_noteon(mSynth, channel, note, velocity); + SPDLOG_TRACE("[FluidSynth] NoteOn ch={} note={} vel={} sfonts={} result={}", channel, note, velocity, + mSfontIds.size(), result); + + // Suppress SF-author-baked LFO-to-pitch on the voices we just started. + // fluid_voice_gen_set() writes the generator's `val` field directly (the SF + // zone value), and final = val + mod + nrpn, so zeroing val drops the SF's + // contribution. Per-voice patching is the only public path that works, since + // the channel-wide set_gen is additive and set_gen2 isn't in the public API. + fluid_voice_t* voices[256]; + fluid_synth_get_voicelist(mSynth, voices, 256, -1); + for (int i = 0; i < 256 && voices[i] != nullptr; ++i) { + if (fluid_voice_get_channel(voices[i]) != channel) + continue; + if (!fluid_voice_is_playing(voices[i])) + continue; + fluid_voice_gen_set(voices[i], GEN_VIBLFOTOPITCH, 0.0f); + fluid_voice_gen_set(voices[i], GEN_MODLFOTOPITCH, 0.0f); + fluid_voice_update_param(voices[i], GEN_VIBLFOTOPITCH); + fluid_voice_update_param(voices[i], GEN_MODLFOTOPITCH); + } +} + +void FluidSynth::NoteOff(uint8_t channel, uint8_t note) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return; + fluid_synth_noteoff(mSynth, channel, note); +} + +void FluidSynth::ProgramChange(uint8_t channel, uint16_t preset) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return; + InitChannel(channel); + + int bank = (preset >> 8) & 0xFF; + int program = preset & 0xFF; + + SPDLOG_TRACE("[FluidSynth] ProgramChange ch={} bank={} program={}", channel, bank, program); + + if (bank == 128) { + fluid_synth_set_channel_type(mSynth, channel, CHANNEL_TYPE_DRUM); + fluid_synth_bank_select(mSynth, channel, 128); + } else { + fluid_synth_set_channel_type(mSynth, channel, CHANNEL_TYPE_MELODIC); + fluid_synth_bank_select(mSynth, channel, bank); + } + + fluid_synth_program_change(mSynth, channel, program); +} + +bool FluidSynth::ProgramSelect(uint8_t channel, int sfontId, uint16_t bank, uint16_t program) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return false; + InitChannel(channel); + + // Verify the sfontId is one we loaded — fluid_synth_program_select + // would also reject an unknown id but its log goes through + // FluidSynth's own logger rather than ours; pre-check so we can + // emit our SPDLOG path uniformly. + bool known = false; + for (int id : mSfontIds) { + if (id == sfontId) { + known = true; + break; + } + } + if (!known) { + SPDLOG_TRACE("[FluidSynth] ProgramSelect ch={} sfontId={} not loaded; rejecting pin", channel, sfontId); + return false; + } + + // Set drum/melodic type before the select — bank 128 is the GM + // percussion convention and FluidSynth's voice allocator branches + // on channel type, not on the bank we're selecting into. + if (bank == 128) { + fluid_synth_set_channel_type(mSynth, channel, CHANNEL_TYPE_DRUM); + } else { + fluid_synth_set_channel_type(mSynth, channel, CHANNEL_TYPE_MELODIC); + } + + int result = fluid_synth_program_select(mSynth, channel, static_cast(sfontId), + static_cast(bank), static_cast(program)); + if (result != FLUID_OK) { + SPDLOG_TRACE("[FluidSynth] ProgramSelect ch={} sfontId={} bank={} prog={} -> FAILED", channel, sfontId, bank, + program); + return false; + } + SPDLOG_TRACE("[FluidSynth] ProgramSelect ch={} sfontId={} bank={} prog={} -> OK", channel, sfontId, bank, program); + return true; +} + +void FluidSynth::PitchBend(uint8_t channel, float semitones) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return; + float ratio = semitones / kPitchBendRangeSemitones; + int val = static_cast(ratio * 8192.0f) + 8192; + val = std::clamp(val, 0, 16383); + fluid_synth_pitch_bend(mSynth, channel, val); +} + +void FluidSynth::ControlChange(uint8_t channel, uint8_t cc, uint16_t value) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return; + fluid_synth_cc(mSynth, channel, cc, (value >> 7) & 0x7F); +} + +void FluidSynth::SetReverbParams(double roomsize, double damping, double width, double level) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return; + fluid_synth_set_reverb_group_roomsize(mSynth, -1, roomsize); + fluid_synth_set_reverb_group_damp(mSynth, -1, damping); + fluid_synth_set_reverb_group_width(mSynth, -1, width); + fluid_synth_set_reverb_group_level(mSynth, -1, level); + SPDLOG_INFO("[FluidSynth] Reverb set: roomsize={} damping={} width={} level={}", roomsize, damping, width, level); +} + +void FluidSynth::SetMasterGain(float gain) { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return; + fluid_synth_set_gain(mSynth, gain); +} + +void FluidSynth::Render(float* out, uint32_t frameCount) { + std::lock_guard lock(mSynthMutex); + if (!mSynth || mSfontIds.empty()) { + std::memset(out, 0, frameCount * 2 * sizeof(float)); + return; + } + + fluid_synth_write_float(mSynth, static_cast(frameCount), out, 0, 2, out, 1, 2); +} + +uint32_t FluidSynth::GetActiveVoiceCount() const { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return 0; + int n = fluid_synth_get_active_voice_count(mSynth); + return n < 0 ? 0u : static_cast(n); +} + +uint32_t FluidSynth::GetPolyphonyLimit() const { + std::lock_guard lock(mSynthMutex); + if (!mSynth) + return 0; + int n = fluid_synth_get_polyphony(mSynth); + return n < 0 ? 0u : static_cast(n); +} + +} // namespace SOH +#endif // ENABLE_FLUIDSYNTH diff --git a/soh/soh/Enhancements/audio/FluidSynth.h b/soh/soh/Enhancements/audio/FluidSynth.h new file mode 100644 index 00000000000..ba44e169c87 --- /dev/null +++ b/soh/soh/Enhancements/audio/FluidSynth.h @@ -0,0 +1,161 @@ +#pragma once +#if ENABLE_FLUIDSYNTH + +#include "IMidiSynth.h" +#include +#include +#include +#include + +namespace SOH { + +// Backend tuning supplied by the integrating game. FluidSynth has no opinion +// on what these should be for a given title, so they are parameters rather than +// hardcoded constants — a game sizes polyphony and gain for its own workload and +// mix. The defaults here are FluidSynth's own stock values, so a consumer that +// leaves a field untouched gets unsurprising upstream behavior. +struct FluidSynthConfig { + // Audio output rate; must match the output device (typically 44100 or 48000). + // Set before new_fluid_synth — the synth reads it once at construction. + double sampleRate = 44100.0; + + // When true, install a softened volume curve: replaces the SF default + // vel / CC7 / CC11 -> initial-attenuation modulators with versions that keep + // the concave NEGATIVE shape but halve the amount (960 -> 480 cB). Maximum + // attenuation drops from -96 dB to -48 dB, lifting quiet voices while + // preserving dynamics shape. False preserves standard SF behavior. + bool linearVelocity = false; + + // Maximum simultaneous voices. FluidSynth's stock default is 256; a game that + // layers many SF voices or holds one-shot percussion voices can exhaust that + // and drop notes. Idle voices cost almost nothing, so sizing up is cheap. + int polyphony = 256; + + // Master output gain. FluidSynth's stock default is 0.2 — conservative to + // avoid clipping when many voices sound at once. A game that mixes the synth + // against a louder source may need to lift this so the two arrive balanced. + double gain = 0.2; +}; + +class FluidSynth final : public IMidiSynth { + public: + explicit FluidSynth(const FluidSynthConfig& config); + ~FluidSynth() override; + + // Single-shot replace: unloads every previously-loaded SF then loads + // this one. Convenience wrapper over ClearSoundFonts + AddSoundFont*. + void LoadSoundFont(const std::string& path) override; + + // Same shape as LoadSoundFont but takes an in-memory SF (e.g. one read + // from a mounted .o2r archive). The buffer is copied into the synth's + // internal storage so the caller may free their copy immediately. + void LoadSoundFontFromMemory(const uint8_t* data, size_t size); + + // Add an SF alongside any already-loaded ones. FluidSynth's preset + // lookup walks loaded soundfonts in REVERSE load order, so the most + // recently added SF wins on (bank, program) collisions — matches the + // "last loaded wins" semantics our mod stack uses elsewhere. + // + // Returns the FluidSynth sfont id on success, or FLUID_FAILED. The + // memory variant copies the buffer into instance-owned storage and + // routes through the mem:// sentinel; the path variant uses the + // default filesystem loader. + int AddSoundFont(const std::string& path); + int AddSoundFontFromMemory(const uint8_t* data, size_t size); + + // Unload every loaded SF. Safe to call when none are loaded. + void ClearSoundFonts(); + + // Loaded SF ids in load order. Use to map a sfont id back to its + // pack name on the caller side (the caller knows what it loaded; + // FluidSynth only knows the opaque ids). + std::vector GetLoadedSfontIds(); + + // One row per preset across every loaded SF (every sfont's full + // preset list, in iteration order — which is generally the SF's + // phdr order, grouped by sfont). Re-enumerated on demand; callers + // typically cache the result and refresh when packs change. + struct LoadedPreset { + int sfontId; + int bank; + int program; + std::string name; + }; + std::vector EnumerateLoadedPresets(); + void NoteOn(uint8_t channel, uint8_t note, uint8_t velocity) override; + void NoteOff(uint8_t channel, uint8_t note) override; + void ProgramChange(uint8_t channel, uint16_t preset) override; + bool ProgramSelect(uint8_t channel, int sfontId, uint16_t bank, uint16_t program) override; + void PitchBend(uint8_t channel, float semitones) override; + void ControlChange(uint8_t channel, uint8_t cc, uint16_t value) override; + void Render(float* out, uint32_t frameCount) override; + uint32_t GetActiveVoiceCount() const override; + uint32_t GetPolyphonyLimit() const override; + + // Configure the synth-wide reverb. Safe to call any time after construction; + // takes the synth mutex. Useful for per-mode presets — callers swap reverb + // settings without having to rebuild the synth. Parameters mirror the + // FluidSynth fluid_synth_set_reverb_* calls: + // roomsize : [0..1] perceived reverb tail length. + // damping : [0..1] high-frequency damping. + // width : [0..100] stereo spread. + // level : [0..1] reverb wet level. + void SetReverbParams(double roomsize, double damping, double width, double level); + + // Set FluidSynth's master output gain at runtime (forwards to + // fluid_synth_set_gain). Lets the host track a global volume fader without + // rebuilding the synth. Takes the synth mutex; safe any time after + // construction. Mirrors FluidSynthConfig::gain, which sets the same knob at + // construction. + void SetMasterGain(float gain) override; + + // Pitch bend range in semitones sent to FluidSynth on channel init. + // Must match what the MidiTranslator uses. Default: 12 semitones. + static constexpr float kPitchBendRangeSemitones = 12.0f; + + private: + void InitChannel(uint8_t channel); + + // Installs the softened volume curve on the freshly-created fluid_synth_t: + // replaces the SF default vel/CC7/CC11 -> attenuation modulators with versions + // at halved amount (480 cB). Must run after new_fluid_synth() but before any + // LoadSoundFont() so SF instrument-level modulators layer correctly on top. + void InstallLinearVelocityModulators(); + + // Installs CC74 -> filter cutoff and CC71 -> filter resonance as default + // modulators. Neither is an SF2.01 default, so without this the host's + // Cutoff/Q sliders are inert on FluidSynth. Must run after new_fluid_synth() + // but before any LoadSoundFont() so instrument-level modulators layer on top. + void InstallFilterCcModulators(); + + fluid_settings_t* mSettings = nullptr; + fluid_synth_t* mSynth = nullptr; + double mSampleRate; + bool mLinearVelocity = false; + + // One entry per loaded SF, in load order. FluidSynth itself walks + // loaded sfonts in reverse load order during preset lookup, so the + // tail of this vector wins on collisions. + std::vector mSfontIds; + + // Backing storage for memory-loaded SFs, paired one-to-one with + // mSfontIds entries. Filesystem-loaded SFs use the default loader + // and the corresponding slot here stays empty. Buffers must outlive + // the sfload call so the mem-sfloader's callbacks have stable data + // for the duration of the load. + std::vector> mLoadedBuffers; + + // Protects fluid_synth_* calls from concurrent access. + // The audio thread calls Render(); the game thread calls NoteOn/Off/etc. + mutable std::mutex mSynthMutex; + + // Which channels have had InitChannel() called. Sized to kNumChannels + // so the translator's per-pair channel allocation can address all of + // them; the synth setting is matched to this in the constructor. + static constexpr int kNumChannels = 64; + bool mChannelInited[kNumChannels] = {}; +}; + +} // namespace SOH + +#endif // ENABLE_FLUIDSYNTH diff --git a/soh/soh/Enhancements/audio/GmInstrumentMap.h b/soh/soh/Enhancements/audio/GmInstrumentMap.h new file mode 100644 index 00000000000..84ee596d252 --- /dev/null +++ b/soh/soh/Enhancements/audio/GmInstrumentMap.h @@ -0,0 +1,388 @@ +#pragma once +#include +#include +#include + +// Forward-declared engine runtime state, populated by audio_load.c at startup, so +// GetFontName can return the real per-build font label instead of the compile-time +// kFontNames snapshot. Each entry is a path like "audio/fonts/..." (built-in) or +// "custom/fonts/MyFont" (modded); nullptr before audio_load runs. +extern "C" char** fontMap; +extern "C" size_t fontMapSize; + +// --------------------------------------------------------------------------- +// Engine soundfont → General MIDI preset mapping +// +// instOrWave values are DIRECT INDICES into the font's sample bank. +// Bank 1 is the main orchestral bank used by most melodic fonts. +// Bank 0 is the SFX bank — all its instruments are [SKIP]. +// +// CONFIDENCE: +// [GM] = exact GM equivalent, high confidence +// [APPROX]= closest available GM program +// [SKIP] = SFX / voice / no usable GM equivalent → kUnmapped (silent +// unless a mod-supplied mapping overrides it) +// +// GM MELODIC PROGRAMS (bank 0): +// 0 Acoustic Grand Piano 19 Church Organ 46 Orchestral Harp +// 1 Bright Acoustic Piano 20 Reed Organ 47 Timpani +// 4 Electric Piano 1 22 Harmonica 48 String Ensemble 1 +// 6 Harpsichord 24 Nylon Guitar 52 Choir Aahs +// 9 Glockenspiel 25 Steel Guitar 53 Voice Oohs +// 11 Vibraphone 30 Distortion Guitar 54 Synth Voice +// 12 Marimba 32 Acoustic Bass 55 Orchestra Hit +// 13 Xylophone 37 Slap Bass 1 56 Trumpet +// 16 Drawbar Organ 40 Violin 57 Trombone +// 17 Percussive Organ 41 Viola 58 Tuba +// 19 Church Organ 42 Cello 60 French Horn +// 21 Accordion 43 Contrabass 68 Oboe +// 23 Tango Accordion 45 Pizzicato Strings 70 Bassoon +// 24 Nylon Guitar 46 Orchestral Harp 71 Clarinet +// 25 Steel Guitar 47 Timpani 73 Flute +// 104 Sitar 107 Koto 114 Steel Drums +// 105 Banjo 112 Tinkle Bell 77 Shakuhachi +// +// GM PERCUSSION (bank 128, channel 9): +// 0 Standard Kit 48 Orchestra Kit +// 8 Room Kit 32 Jazz Kit +// --------------------------------------------------------------------------- + +namespace SOH { + +static constexpr uint8_t kUnmapped = 0xFF; +static constexpr uint8_t kBuiltinFontCount = 38; + +struct GmPreset { + uint8_t bank; // 0 = GM melodic, 128 = GM percussion + uint8_t program; // melodic: GM program; drums: GM drum kit (Standard=0, Orchestra=48, …) + uint8_t drumNote; // Only meaningful when bank == 128. The GM percussion MIDI note to + // fire on channel 9 for this engine instrument. 0 = no specific + // mapping; the translator falls back to a heuristic based on the + // engine semitone. +}; + +static constexpr uint16_t kMaxInstPerFont = 128; // bank 1 has 85 entries; leave headroom +static constexpr GmPreset U = { 0, kUnmapped, 0 }; + +// --------------------------------------------------------------------------- +// Per-font friendly names, indexed by fontId. Used by the bypass UI to label +// discovered (fontId, instOrWave) pairs with a name instead of a raw integer +// pair. Names mirror Audio.xml's entries, with +// the "NN_" prefix stripped and underscores turned into spaces. +// +// There is no per-slot instrument-name table: the engine reads +// sf->instruments[instId] per font, each font having its own instruments array +// in its own order, so there is no shared bank layout to name. +// --------------------------------------------------------------------------- +// clang-format off + +static const char* const kFontNames[] = { + /* 0 */ "Sound Effects 1", + /* 1 */ "Sound Effects 2", + /* 2 */ "Ambient Sounds", + /* 3 */ "Orchestra", + /* 4 */ "Deku Tree", + /* 5 */ "Market", + /* 6 */ "Title Theme", + /* 7 */ "Jabu Jabu", + /* 8 */ "Child Kakariko Village", + /* 9 */ "Fairy Fountain", + /* 10 */ "Fire Temple", + /* 11 */ "Dodongos Cavern", + /* 12 */ "Forest Temple", + /* 13 */ "Lon Lon Ranch", + /* 14 */ "Goron City", + /* 15 */ "Kokiri Forest", + /* 16 */ "Spirit Temple", + /* 17 */ "Horse Race", + /* 18 */ "Warp Songs", + /* 19 */ "Legends of Hyrule", + /* 20 */ "Minigames", + /* 21 */ "Zoras Domain", + /* 22 */ "Shops", + /* 23 */ "Ice Cavern", + /* 24 */ "Shadow Temple", + /* 25 */ "Water Temple", + /* 26 */ "Unused", + /* 27 */ "Gerudo Valley", + /* 28 */ "Lakeside Laboratory", + /* 29 */ "Kotake and Koumes Theme", + /* 30 */ "Ganons Castle Organ", + /* 31 */ "Ganons Castle", + /* 32 */ "Ganondorfs Battle", + /* 33 */ "Ending 1", + /* 34 */ "Ending 2", + /* 35 */ "Game Over", + /* 36 */ "Kaepora Gaeboras Theme", + /* 37 */ "Unused Deku Tree", +}; +static_assert(sizeof(kFontNames) / sizeof(kFontNames[0]) == kBuiltinFontCount, + "kFontNames must cover every builtin font"); + +// clang-format on + +// --------------------------------------------------------------------------- +// Lookup, called from MidiTranslator::ProcessNote. Returns the unmapped sentinel +// for everything melodic; the JSON layer (defaults + user overrides) drives +// program assignments via mProgramOverride. Only structural cases stay here: +// synthetic waves, SFX-only fonts, and the true-drum trigger stay unmapped so the +// native synth plays them. +// --------------------------------------------------------------------------- +inline GmPreset GetGmPreset(uint8_t fontId, int16_t instOrWave) { + // Synthetic waveforms (square, triangle...): no SF equivalent. + if (instOrWave >= 0x80 && instOrWave < 0xC0) + return U; + if (instOrWave < 0) + return U; + + // Fonts 0 & 1: pure SFX bank — never map to GM. + if (fontId == 0 || fontId == 1) + return U; + + // Font 2: ambient sounds / loops — pure SFX-flavoured content. + if (fontId == 2) + return U; + + // True drum trigger: the engine fetches Audio_GetDrum(fontId, semitone) from a + // per-font drum bank. Leave unmapped so the native path handles it; a blanket + // GM percussion fallback clicks on note attack. + if (instOrWave == 0) + return U; + + // Everything else: the per-pair JSON drives the mapping. Return U so + // BypassMode::Auto resolves to native; mProgramOverride is what actually + // routes a pair through FluidSynth when set. + return U; +} + +// --------------------------------------------------------------------------- +// Friendly label for a fontId. The bypass UI combines this with the raw +// instOrWave index; there is no reliable per-slot instrument name (see the +// kFontNames comment). Returns nullptr for fontIds outside the built-in range +// (typically modded fonts). +// --------------------------------------------------------------------------- +inline const char* GetFontName(uint8_t fontId) { + // Prefer the runtime fontMap, which tracks the actually loaded assets + // (including modded fonts). Strip the directory prefix so the UI shows just + // the final segment ("audio/fonts/Foo" -> "Foo"). Pointers are stable: entries + // are allocated once during audio_load and never reallocated. + if (fontMap != nullptr && fontId < fontMapSize && fontMap[fontId] != nullptr) { + const char* path = fontMap[fontId]; + const char* slash = std::strrchr(path, '/'); + return slash ? slash + 1 : path; + } + // Fallback to the compile-time table for very-early-startup calls before + // audio_load populates fontMap; only built-in fonts exist at that point anyway. + if (fontId < kBuiltinFontCount) + return kFontNames[fontId]; + return nullptr; +} + +// Convenience returning the same string as GetFontName. The instOrWave argument +// is unused, kept so call sites don't change shape. +inline const char* GetInstrumentName(uint8_t fontId, int16_t /*instOrWave*/) { + return GetFontName(fontId); +} + +// --------------------------------------------------------------------------- +// General MIDI program names — used by the debug UI's GM Prg dropdown so the +// user picks "Acoustic Grand Piano" instead of "0". Indexed by GM program +// number 0..127. Standard GM Level 1 instrument set. +// --------------------------------------------------------------------------- +static const char* const kGmProgramNames[128] = { + /* 0 */ "Acoustic Grand Piano", + /* 1 */ "Bright Acoustic Piano", + /* 2 */ "Electric Grand Piano", + /* 3 */ "Honky-tonk Piano", + /* 4 */ "Electric Piano 1", + /* 5 */ "Electric Piano 2", + /* 6 */ "Harpsichord", + /* 7 */ "Clavi", + /* 8 */ "Celesta", + /* 9 */ "Glockenspiel", + /* 10 */ "Music Box", + /* 11 */ "Vibraphone", + /* 12 */ "Marimba", + /* 13 */ "Xylophone", + /* 14 */ "Tubular Bells", + /* 15 */ "Dulcimer", + /* 16 */ "Drawbar Organ", + /* 17 */ "Percussive Organ", + /* 18 */ "Rock Organ", + /* 19 */ "Church Organ", + /* 20 */ "Reed Organ", + /* 21 */ "Accordion", + /* 22 */ "Harmonica", + /* 23 */ "Tango Accordion", + /* 24 */ "Acoustic Guitar (nylon)", + /* 25 */ "Acoustic Guitar (steel)", + /* 26 */ "Electric Guitar (jazz)", + /* 27 */ "Electric Guitar (clean)", + /* 28 */ "Electric Guitar (muted)", + /* 29 */ "Overdriven Guitar", + /* 30 */ "Distortion Guitar", + /* 31 */ "Guitar Harmonics", + /* 32 */ "Acoustic Bass", + /* 33 */ "Electric Bass (finger)", + /* 34 */ "Electric Bass (pick)", + /* 35 */ "Fretless Bass", + /* 36 */ "Slap Bass 1", + /* 37 */ "Slap Bass 2", + /* 38 */ "Synth Bass 1", + /* 39 */ "Synth Bass 2", + /* 40 */ "Violin", + /* 41 */ "Viola", + /* 42 */ "Cello", + /* 43 */ "Contrabass", + /* 44 */ "Tremolo Strings", + /* 45 */ "Pizzicato Strings", + /* 46 */ "Orchestral Harp", + /* 47 */ "Timpani", + /* 48 */ "String Ensemble 1", + /* 49 */ "String Ensemble 2", + /* 50 */ "Synth Strings 1", + /* 51 */ "Synth Strings 2", + /* 52 */ "Choir Aahs", + /* 53 */ "Voice Oohs", + /* 54 */ "Synth Voice", + /* 55 */ "Orchestra Hit", + /* 56 */ "Trumpet", + /* 57 */ "Trombone", + /* 58 */ "Tuba", + /* 59 */ "Muted Trumpet", + /* 60 */ "French Horn", + /* 61 */ "Brass Section", + /* 62 */ "Synth Brass 1", + /* 63 */ "Synth Brass 2", + /* 64 */ "Soprano Sax", + /* 65 */ "Alto Sax", + /* 66 */ "Tenor Sax", + /* 67 */ "Baritone Sax", + /* 68 */ "Oboe", + /* 69 */ "English Horn", + /* 70 */ "Bassoon", + /* 71 */ "Clarinet", + /* 72 */ "Piccolo", + /* 73 */ "Flute", + /* 74 */ "Recorder", + /* 75 */ "Pan Flute", + /* 76 */ "Blown Bottle", + /* 77 */ "Shakuhachi", + /* 78 */ "Whistle", + /* 79 */ "Ocarina", + /* 80 */ "Lead 1 (square)", + /* 81 */ "Lead 2 (sawtooth)", + /* 82 */ "Lead 3 (calliope)", + /* 83 */ "Lead 4 (chiff)", + /* 84 */ "Lead 5 (charang)", + /* 85 */ "Lead 6 (voice)", + /* 86 */ "Lead 7 (fifths)", + /* 87 */ "Lead 8 (bass + lead)", + /* 88 */ "Pad 1 (new age)", + /* 89 */ "Pad 2 (warm)", + /* 90 */ "Pad 3 (polysynth)", + /* 91 */ "Pad 4 (choir)", + /* 92 */ "Pad 5 (bowed)", + /* 93 */ "Pad 6 (metallic)", + /* 94 */ "Pad 7 (halo)", + /* 95 */ "Pad 8 (sweep)", + /* 96 */ "FX 1 (rain)", + /* 97 */ "FX 2 (soundtrack)", + /* 98 */ "FX 3 (crystal)", + /* 99 */ "FX 4 (atmosphere)", + /* 100 */ "FX 5 (brightness)", + /* 101 */ "FX 6 (goblins)", + /* 102 */ "FX 7 (echoes)", + /* 103 */ "FX 8 (sci-fi)", + /* 104 */ "Sitar", + /* 105 */ "Banjo", + /* 106 */ "Shamisen", + /* 107 */ "Koto", + /* 108 */ "Kalimba", + /* 109 */ "Bagpipe", + /* 110 */ "Fiddle", + /* 111 */ "Shanai", + /* 112 */ "Tinkle Bell", + /* 113 */ "Agogo", + /* 114 */ "Steel Drums", + /* 115 */ "Woodblock", + /* 116 */ "Taiko Drum", + /* 117 */ "Melodic Tom", + /* 118 */ "Synth Drum", + /* 119 */ "Reverse Cymbal", + /* 120 */ "Guitar Fret Noise", + /* 121 */ "Breath Noise", + /* 122 */ "Seashore", + /* 123 */ "Bird Tweet", + /* 124 */ "Telephone Ring", + /* 125 */ "Helicopter", + /* 126 */ "Applause", + /* 127 */ "Gunshot", +}; + +// --------------------------------------------------------------------------- +// General MIDI percussion names — used by the drum-split UI's "Drum Sound" +// combo so a slot picks "Acoustic Snare" instead of "38". GM percussion is +// defined for MIDI notes 35..81. ASCII only (the bundled ImGui font lacks +// extended glyphs). +// --------------------------------------------------------------------------- +static constexpr uint8_t kGmPercussionLo = 35; +static constexpr uint8_t kGmPercussionHi = 81; +static const char* const kGmPercussionNames[] = { + /* 35 */ "Acoustic Bass Drum", + /* 36 */ "Bass Drum 1", + /* 37 */ "Side Stick", + /* 38 */ "Acoustic Snare", + /* 39 */ "Hand Clap", + /* 40 */ "Electric Snare", + /* 41 */ "Low Floor Tom", + /* 42 */ "Closed Hi-Hat", + /* 43 */ "High Floor Tom", + /* 44 */ "Pedal Hi-Hat", + /* 45 */ "Low Tom", + /* 46 */ "Open Hi-Hat", + /* 47 */ "Low-Mid Tom", + /* 48 */ "Hi-Mid Tom", + /* 49 */ "Crash Cymbal 1", + /* 50 */ "High Tom", + /* 51 */ "Ride Cymbal 1", + /* 52 */ "Chinese Cymbal", + /* 53 */ "Ride Bell", + /* 54 */ "Tambourine", + /* 55 */ "Splash Cymbal", + /* 56 */ "Cowbell", + /* 57 */ "Crash Cymbal 2", + /* 58 */ "Vibraslap", + /* 59 */ "Ride Cymbal 2", + /* 60 */ "Hi Bongo", + /* 61 */ "Low Bongo", + /* 62 */ "Mute Hi Conga", + /* 63 */ "Open Hi Conga", + /* 64 */ "Low Conga", + /* 65 */ "High Timbale", + /* 66 */ "Low Timbale", + /* 67 */ "High Agogo", + /* 68 */ "Low Agogo", + /* 69 */ "Cabasa", + /* 70 */ "Maracas", + /* 71 */ "Short Whistle", + /* 72 */ "Long Whistle", + /* 73 */ "Short Guiro", + /* 74 */ "Long Guiro", + /* 75 */ "Claves", + /* 76 */ "Hi Wood Block", + /* 77 */ "Low Wood Block", + /* 78 */ "Mute Cuica", + /* 79 */ "Open Cuica", + /* 80 */ "Mute Triangle", + /* 81 */ "Open Triangle", +}; + +// Name for a GM percussion note, or "" if outside the GM percussion range. +inline const char* GmPercussionName(int note) { + if (note < kGmPercussionLo || note > kGmPercussionHi) + return ""; + return kGmPercussionNames[note - kGmPercussionLo]; +} + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/IMidiSynth.h b/soh/soh/Enhancements/audio/IMidiSynth.h new file mode 100644 index 00000000000..13492c09eca --- /dev/null +++ b/soh/soh/Enhancements/audio/IMidiSynth.h @@ -0,0 +1,93 @@ +#pragma once +#include +#include +#include + +namespace SOH { + +// MIDI-shaped soft synth interface. An implementation that is installed +// on the MidiSynthManager replaces the engine's native audio synthesis +// path: the audio thread fills its output buffer by calling Render() +// instead of running the native synth. +// +// When no implementation is installed, the manager returns nullptr and +// the audio thread falls back to native synthesis. +class IMidiSynth { + public: + virtual ~IMidiSynth() = default; + + // Load an SF soundfont from disk. Implementations that do not use + // SF may treat this as a no-op. + virtual void LoadSoundFont(const std::string& path) = 0; + + // MIDI-like note events. channel index is implementation-defined; the + // current FluidSynth backend exposes 64 channels. Standard MIDI drum + // semantics are NOT pinned to channel 9 — drum vs melodic is decided + // by the bank passed to ProgramChange (bank 128 = drum kit), and the + // implementation flips the channel type per call. + virtual void NoteOn(uint8_t channel, uint8_t note, uint8_t velocity) = 0; + virtual void NoteOff(uint8_t channel, uint8_t note) = 0; + + // preset encodes both bank (high byte) and program (low byte). + // The synth's preset lookup resolves this against the union of every + // loaded soundfont — typically with last-loaded-wins precedence. + virtual void ProgramChange(uint8_t channel, uint16_t preset) = 0; + + // Like ProgramChange but pins the channel to a SPECIFIC loaded soundfont via + // its `sfontId`, bypassing the cross-soundfont preset lookup. Use when the + // caller knows exactly which SF the preset must come from, even if another + // loaded SF also has that (bank, program). + // + // Returns true when the pin succeeds (the sfontId is valid and has the + // (bank, program) tuple), false otherwise. Failure signals the caller to fall + // back to native synthesis for this entry. + virtual bool ProgramSelect(uint8_t channel, int sfontId, uint16_t bank, uint16_t program) = 0; + + // semitones is a signed float: +1.0 = one semitone up. The + // implementation owns the usable range and clamps out-of-range values + // (the FluidSynth backend clamps to its configured pitch-wheel range, + // approximately +/-12 semitones), so callers need not pre-clamp. + virtual void PitchBend(uint8_t channel, float semitones) = 0; + + // Convenience: bend by a frequency RATIO instead of semitones. + // 1.0 = no bend, 2.0 = +1 octave, 0.5 = -1 octave. Handy for engines + // that express pitch as a frequency/resampling scale rather than in + // semitones. Forwards to PitchBend, which owns the range clamp. + void PitchBendFactor(uint8_t channel, float freqRatio) { + PitchBend(channel, 12.0f * std::log2(freqRatio > 0.0f ? freqRatio : 1e-6f)); + } + + // Convenience: start a note already pitch-bent by `freqRatio` (same + // convention as PitchBendFactor). The bend is applied BEFORE the NoteOn + // so the voice attacks at the bent pitch in one step, rather than + // sounding at concert pitch until the next bend update lands. + void NoteOnPitchFactor(uint8_t channel, uint8_t note, uint8_t velocity, float freqRatio) { + PitchBendFactor(channel, freqRatio); + NoteOn(channel, note, velocity); + } + + // Standard MIDI CC. value is 0-16383 (14-bit). + virtual void ControlChange(uint8_t channel, uint8_t cc, uint16_t value) = 0; + + // Fill `out` with `frameCount` stereo interleaved float32 samples. + // Called from the audio thread; must be real-time safe. + virtual void Render(float* out, uint32_t frameCount) = 0; + + // Current number of audible voices held by the synth. Used by host UIs + // as a real-time diagnostic — when this approaches GetPolyphonyLimit(), + // new NoteOns will steal existing voices and the host can correlate + // user-reported "cuts" with voice exhaustion. Implementations without + // a voice pool may return 0. + virtual uint32_t GetActiveVoiceCount() const = 0; + virtual uint32_t GetPolyphonyLimit() const = 0; + + // Set the synth-wide master output gain (linear; 1.0 = unity). The host + // uses this to apply a global volume fader to the synth's contribution + // without rebuilding it — e.g. tracking a Master Volume slider. Safe to + // call from the game thread. Implementations without a controllable master + // gain may treat this as a no-op. + virtual void SetMasterGain(float gain) { + } +}; + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/InstrumentNames.cpp b/soh/soh/Enhancements/audio/InstrumentNames.cpp new file mode 100644 index 00000000000..60fb693778d --- /dev/null +++ b/soh/soh/Enhancements/audio/InstrumentNames.cpp @@ -0,0 +1,148 @@ +#include "InstrumentNames.h" +#include +#include +#include +#include +#include + +namespace SOH { + +namespace { +// One slot may carry up to three sample variants (low / normal / high). +// Storing them together keeps lookups single-keyed. +struct SlotSamples { + std::string low; + std::string normal; + std::string high; + uint8_t rangeLo = 0; + uint8_t rangeHi = 127; + bool hasRange = false; + float lowTuning = 0.0f; + float normalTuning = 0.0f; + float highTuning = 0.0f; +}; + +struct State { + std::mutex mutex; + // Keyed by (fontId, instId). Small enough that std::map's cost is irrelevant. + std::map, SlotSamples> entries; +}; + +State& GetState() { + static State s; + return s; +} +} // namespace + +void SetInstrumentSampleName(uint8_t fontId, int16_t instId, SampleRange range, std::string name) { + if (instId < 0) { + return; + } + auto& s = GetState(); + std::lock_guard lock(s.mutex); + auto& slot = s.entries[std::make_pair(fontId, instId)]; + switch (range) { + case SampleRange::Low: + slot.low = std::move(name); + break; + case SampleRange::Normal: + slot.normal = std::move(name); + break; + case SampleRange::High: + slot.high = std::move(name); + break; + } +} + +void SetInstrumentTuning(uint8_t fontId, int16_t instId, SampleRange range, float tuning) { + if (instId < 0) { + return; + } + auto& s = GetState(); + std::lock_guard lock(s.mutex); + auto& slot = s.entries[std::make_pair(fontId, instId)]; + switch (range) { + case SampleRange::Low: + slot.lowTuning = tuning; + break; + case SampleRange::Normal: + slot.normalTuning = tuning; + break; + case SampleRange::High: + slot.highTuning = tuning; + break; + } +} + +void SetInstrumentRange(uint8_t fontId, int16_t instId, uint8_t normalRangeLo, uint8_t normalRangeHi) { + if (instId < 0) { + return; + } + auto& s = GetState(); + std::lock_guard lock(s.mutex); + auto& slot = s.entries[std::make_pair(fontId, instId)]; + slot.rangeLo = normalRangeLo; + slot.rangeHi = normalRangeHi; + slot.hasRange = true; +} + +InstrumentSampleSet GetInstrumentSampleNames(uint8_t fontId, int16_t instId) { + InstrumentSampleSet out; + if (instId < 0) { + return out; + } + auto& s = GetState(); + std::lock_guard lock(s.mutex); + auto it = s.entries.find(std::make_pair(fontId, instId)); + if (it == s.entries.end()) { + return out; + } + out.low = it->second.low; + out.normal = it->second.normal; + out.high = it->second.high; + out.rangeLo = it->second.rangeLo; + out.rangeHi = it->second.rangeHi; + out.hasRange = it->second.hasRange; + out.lowTuning = it->second.lowTuning; + out.normalTuning = it->second.normalTuning; + out.highTuning = it->second.highTuning; + return out; +} + +int SuggestedOctaveShift(float tuning) { + if (tuning <= 0.0f) { + return 0; + } + int octaves = static_cast(std::lround(std::log2(tuning))); + return std::clamp(octaves * 12, -48, 48); +} + +int SuggestedTranspose(uint8_t fontId, int16_t instId, uint8_t noteLow, uint8_t noteHigh) { + const InstrumentSampleSet set = GetInstrumentSampleNames(fontId, instId); + // Route the range midpoint through the engine's low/normal/high split to pick + // the sample that governs most of this range, then fall back to whatever tuning + // is actually registered (many slots only have a normal sample). + const int mid = (static_cast(noteLow) + static_cast(noteHigh)) / 2; + float tuning; + if (mid < set.rangeLo) { + tuning = set.lowTuning; + } else if (mid > set.rangeHi) { + tuning = set.highTuning; + } else { + tuning = set.normalTuning; + } + if (tuning <= 0.0f) { + tuning = set.normalTuning > 0.0f ? set.normalTuning : set.lowTuning > 0.0f ? set.lowTuning : set.highTuning; + } + return SuggestedOctaveShift(tuning); +} + +const char* StripSamplePathPrefix(const std::string& path) { + const size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) { + return path.c_str(); + } + return path.c_str() + slash + 1; +} + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/InstrumentNames.h b/soh/soh/Enhancements/audio/InstrumentNames.h new file mode 100644 index 00000000000..0fee65db501 --- /dev/null +++ b/soh/soh/Enhancements/audio/InstrumentNames.h @@ -0,0 +1,81 @@ +#pragma once +#include +#include + +namespace SOH { + +// Per-(fontId, instId) sample-name registry, populated by the SoundFont resource +// factory as it loads each font. Lets the bypass UI show a real sample name +// rather than a generic per-font label. Set runs during resource loading (any +// thread), Get on the GUI thread, so both take the mutex. + +// Range identifier for the three sample variants each instrument can carry. The +// engine picks one per note pitch (low for very low notes, high for very high, +// normal for the middle range); many instruments only populate one or two. +enum class SampleRange : uint8_t { Low = 0, Normal = 1, High = 2 }; + +// The audio engine exposes a melodic instrument as instOrWave = instrument-array +// index + 2; instOrWave 0 and 1 are the drum and SFX channels (see +// AudioSeq_SetInstrument and MidiTranslator::kDrumHistInst). Every consumer keys +// by instOrWave, so the factory must register under (array index + this base). +inline constexpr int16_t kMelodicInstOrWaveBase = 2; + +// Record one sample filename for an (instrument slot, range). Pass the raw +// string the factory loaded (with directory prefix) — the helper strips it +// on display. Empty `name` clears any prior entry for that range. +void SetInstrumentSampleName(uint8_t fontId, int16_t instId, SampleRange range, std::string name); + +// Record one sample's tuning (frequency scale factor) for an (instrument slot, +// range). The engine resamples by this ratio, so a value near a power of two +// encodes a whole-octave displacement of the sample vs its nominal note; the +// bypass UI derives a suggested octave Shift from it (see SuggestedTranspose). +void SetInstrumentTuning(uint8_t fontId, int16_t instId, SampleRange range, float tuning); + +// Record the engine's low/normal/high split boundaries for an instrument slot +// (in engine-semitone space): semitone < lo -> low sample, lo..hi -> normal, +// semitone > hi -> high. Lets the bypass UI mirror the engine's per-pitch +// sample routing. +void SetInstrumentRange(uint8_t fontId, int16_t instId, uint8_t normalRangeLo, uint8_t normalRangeHi); + +// Snapshot of all three range names for an instrument slot; empty strings mean no +// sample is registered for that range. Lets the bypass UI disambiguate slots that +// play a different sample per pitch. +struct InstrumentSampleSet { + std::string low; + std::string normal; + std::string high; + // Engine split boundaries (see SetInstrumentRange). hasRange is false when the + // factory never registered this slot; callers then fall back to a single + // full-range entry. + uint8_t rangeLo = 0; + uint8_t rangeHi = 127; + bool hasRange = false; + // Per-range sample tuning (0 = no sample / not registered). See + // SetInstrumentTuning and SuggestedTranspose. + float lowTuning = 0.0f; + float normalTuning = 0.0f; + float highTuning = 0.0f; + bool empty() const { + return low.empty() && normal.empty() && high.empty(); + } +}; +InstrumentSampleSet GetInstrumentSampleNames(uint8_t fontId, int16_t instId); + +// Octave-rounded semitone shift implied by a sample tuning: round(log2(tuning)) +// * 12. Only the whole-octave part is taken — the fine cents in `tuning` are the +// sample's own pitch correction, irrelevant to an in-tune GM substitute. Returns +// 0 for tuning <= 0 (no sample). Clamped to +/-48 st. +int SuggestedOctaveShift(float tuning); + +// Suggested per-entry transpose for the [noteLow, noteHigh] range of an +// instrument slot: routes the range midpoint through the engine's low/normal/ +// high split to pick the governing sample's tuning, then octave-rounds it. +// Returns 0 when no tuning is registered for the slot. +int SuggestedTranspose(uint8_t fontId, int16_t instId, uint8_t noteLow, uint8_t noteHigh); + +// Convenience: extract the basename from a stored sample path. Returns +// the input unchanged if it has no '/' separator. The returned pointer +// references inside `path` — do not outlive it. +const char* StripSamplePathPrefix(const std::string& path); + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/MidiSynthManager.cpp b/soh/soh/Enhancements/audio/MidiSynthManager.cpp new file mode 100644 index 00000000000..c0c3fbcfaa2 --- /dev/null +++ b/soh/soh/Enhancements/audio/MidiSynthManager.cpp @@ -0,0 +1,20 @@ +#include "soh/Enhancements/audio/MidiSynthManager.h" + +namespace SOH { + +MidiSynthManager& MidiSynthManager::Instance() { + static MidiSynthManager sInstance; + return sInstance; +} + +void MidiSynthManager::SetSynth(std::shared_ptr synth) { + std::lock_guard lock(mMutex); + mSynth = std::move(synth); +} + +std::shared_ptr MidiSynthManager::GetActiveSynth() { + std::lock_guard lock(mMutex); + return mSynth; +} + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/MidiSynthManager.h b/soh/soh/Enhancements/audio/MidiSynthManager.h new file mode 100644 index 00000000000..8fd469e0c06 --- /dev/null +++ b/soh/soh/Enhancements/audio/MidiSynthManager.h @@ -0,0 +1,30 @@ +#pragma once +#include "IMidiSynth.h" +#include +#include + +namespace SOH { + +// Owns the optional IMidiSynth that, when present, replaces the engine's +// native audio synthesis. When no synth is installed, GetActiveSynth() +// returns nullptr and the audio thread should fall back to the native +// path. +class MidiSynthManager { + public: + static MidiSynthManager& Instance(); + + // Install or remove the active synth. Thread-safe. + // Must NOT be called from the audio thread. + // Passing nullptr uninstalls (native synthesis takes over). + void SetSynth(std::shared_ptr synth); + + // Returns the installed synth, or nullptr if native synthesis is active. + std::shared_ptr GetActiveSynth(); + + private: + MidiSynthManager() = default; + std::shared_ptr mSynth; + std::mutex mMutex; +}; + +} // namespace SOH diff --git a/soh/soh/Enhancements/audio/MidiTranslator.cpp b/soh/soh/Enhancements/audio/MidiTranslator.cpp new file mode 100644 index 00000000000..cd1ea063edc --- /dev/null +++ b/soh/soh/Enhancements/audio/MidiTranslator.cpp @@ -0,0 +1,2165 @@ +#include "MidiTranslator.h" +#include "GmInstrumentMap.h" +#include "InstrumentNames.h" +#include "soh/Enhancements/audio/MidiSynthManager.h" +#include +#include +#include +#include +#include +#include + +#ifdef DEBUG_FONT_MAP +#include +#include +static FILE* sDbgFile = nullptr; +static std::mutex sDbgMutex; + +static void DbgLogNote(uint8_t fontId, int16_t instOrWave, uint8_t semitone, float freqScale, bool mapped, + uint8_t gmBank, uint8_t gmProg) { + std::lock_guard lk(sDbgMutex); + if (!sDbgFile) { + sDbgFile = fopen("font_map_dump.csv", "w"); + if (sDbgFile) + fprintf(sDbgFile, "fontId,instOrWave,instOrWave_hex,semitone,expectedMidi," + "freqScale,mapped,gmBank,gmProgram\n"); + } + if (sDbgFile) + fprintf(sDbgFile, "%u,%d,0x%02X,%u,%d,%.6f,%s,%u,%u\n", fontId, instOrWave, (uint8_t)instOrWave, semitone, + static_cast(semitone) + 21, freqScale, mapped ? "yes" : "no", gmBank, gmProg); +} +#define DBG_LOG(fontId, iow, semi, freq, mapped, bank, prog) \ + DbgLogNote((fontId), (iow), (semi), (freq), (mapped), (bank), (prog)) +#else +#define DBG_LOG(...) (void)0 +#endif + +namespace SOH { + +// Engine semitone -> MIDI note offset for melodic instruments. +// +// The engine's gNoteFrequencies table labels semitone 39 as "NOTE_C4 +// (Middle C)" with freqScale 1.0, which gives the textbook offset of +// 60 - 39 = 21 against standard GM tuning. +static constexpr int kEngineSemitoneToMidiOffset = 21; + +// Held near-max so the SF's default velocity attenuation modulator +// doesn't silence quiet notes. Dynamics ride CC11 instead (Authentic +// mode) or NoteOn velocity (Enhanced mode). +static constexpr uint8_t kFixedNoteOnVelocity = 100; + +MidiTranslator::MidiTranslator() { + // Reserve mEntries capacity so push_back never reallocates. The audio + // thread reads entry fields by index without locking; a reallocation + // would invalidate those reads. + mEntries.reserve(kMaxEntries); + // -1 = "no active entry" sentinel; native plays for that pair. + for (auto& row : mActiveEntryIdx) + for (auto& cell : row) + cell = -1; + // 0xFF = unallocated MIDI channel slot. + for (auto& row : mPairChannel) + for (auto& cell : row) + cell = 0xFF; + // -1 = pair not forced-drum (and not holding a discovery-pool slot). + for (auto& row : mForcedDrumPool) + for (auto& cell : row) + cell = -1; +} + +MidiTranslator& MidiTranslator::Instance() { + static MidiTranslator sInstance; + return sInstance; +} + +void MidiTranslator::Reset() { + for (NoteTranslatorState& noteState : mNoteState) + noteState = {}; + for (ChannelState& chState : mChannelState) + chState = {}; + for (auto& row : mSynthActiveByPair) + for (auto& cell : row) + cell = 0; + for (auto& row : mNativeActiveByPair) + for (auto& cell : row) + cell = 0; + for (auto& c : mEntrySynthActive) + c = 0; + for (auto& c : mEntryNativeActive) + c = 0; + auto synth = SOH::MidiSynthManager::Instance().GetActiveSynth(); + if (!synth) + return; + for (uint8_t ch = 0; ch < kMaxMidiChannels; ch++) { + synth->ControlChange(ch, 123, 0); // CC 123 = All Notes Off + } +} + +static inline bool BypassIndexValid(uint8_t fontId, int16_t instOrWave) { + return fontId < MidiTranslator::kMaxFontId && instOrWave >= 0 && instOrWave < MidiTranslator::kMaxInstOrWave; +} + +// ── Discovery + active-voice counters ───────────────────────────────────── + +int MidiTranslator::DiscoveredSnapshot(DiscoveredPair* out, int outCap) const { + int n = mDiscoveredCount.load(std::memory_order_acquire); + if (n > outCap) + n = outCap; + for (int i = 0; i < n; i++) + out[i] = mDiscovered[i]; + return n; +} + +void MidiTranslator::ClearDiscovered() { + mDiscoveredCount.store(0, std::memory_order_release); + for (auto& word : mSeenBits) + word = 0; + for (auto& entry : mDiscovered) + entry = {}; +} + +uint8_t MidiTranslator::GetSynthActiveCount(uint8_t fontId, int16_t instOrWave) const { + if (!BypassIndexValid(fontId, instOrWave)) + return 0; + return mSynthActiveByPair[fontId][instOrWave]; +} + +uint8_t MidiTranslator::GetNativeActiveCount(uint8_t fontId, int16_t instOrWave) const { + if (!BypassIndexValid(fontId, instOrWave)) + return 0; + return mNativeActiveByPair[fontId][instOrWave]; +} + +void MidiTranslator::IncEntryActive(bool synth, int idx) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; // native fall-through has no entry + uint8_t& c = synth ? mEntrySynthActive[idx] : mEntryNativeActive[idx]; + if (c < 255) + c++; +} +void MidiTranslator::DecEntryActive(bool synth, int idx) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + uint8_t& c = synth ? mEntrySynthActive[idx] : mEntryNativeActive[idx]; + if (c > 0) + c--; +} +uint8_t MidiTranslator::GetEntrySynthActive(int idx) const { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return 0; + return mEntrySynthActive[idx]; +} +uint8_t MidiTranslator::GetEntryNativeActive(int idx) const { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return 0; + return mEntryNativeActive[idx]; +} +void MidiTranslator::GetPairEntryActivity(uint8_t fontId, int16_t instOrWave, bool& anySynth, bool& anyNative) const { + anySynth = false; + anyNative = false; + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + if (e.fontId != fontId || e.instOrWave != instOrWave) + continue; + if (mEntrySynthActive[idx] > 0) + anySynth = true; + else if (mEntryNativeActive[idx] > 0) + anyNative = true; + } +} + +// ── DEBUG: per-pair stats accessors ────────────────────────────────────── + +MidiTranslator::DebugPairStats MidiTranslator::GetDebugStats(uint8_t fontId, int16_t instOrWave) const { + DebugPairStats out{}; + if (!BypassIndexValid(fontId, instOrWave)) + return out; + const auto& s = mDebugStats[fontId][instOrWave]; + out.noteOns = s.noteOns.load(std::memory_order_relaxed); + out.routedSynth = s.routedSynth.load(std::memory_order_relaxed); + out.routedNative = s.routedNative.load(std::memory_order_relaxed); + out.routedMute = s.routedMute.load(std::memory_order_relaxed); + out.lastSemitone = s.lastSemitone; + return out; +} + +int MidiTranslator::GetDrumSlotHistogram(uint8_t fontId, int16_t instOrWave, uint32_t out[128]) const { + for (int s = 0; s < kDrumHistSlots; ++s) + out[s] = 0; + const DrumSlotHit* hist = DrumHistFor(fontId, instOrWave); + if (!hist) + return 0; + int distinct = 0; + for (int s = 0; s < kDrumHistSlots; ++s) { + uint32_t c = hist[s].count.load(std::memory_order_relaxed); + out[s] = c; + if (c > 0) + ++distinct; + } + return distinct; +} + +void MidiTranslator::ResetDebugStats() { + for (int f = 0; f < kMaxFontId; ++f) + for (int i = 0; i < kMaxInstOrWave; ++i) + ResetDebugStatsForPair(static_cast(f), static_cast(i)); +} + +void MidiTranslator::ResetDebugStatsForPair(uint8_t fontId, int16_t instOrWave) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + auto& s = mDebugStats[fontId][instOrWave]; + s.noteOns.store(0, std::memory_order_relaxed); + s.routedSynth.store(0, std::memory_order_relaxed); + s.routedNative.store(0, std::memory_order_relaxed); + s.routedMute.store(0, std::memory_order_relaxed); + s.lastSemitone = 0; + if (DrumSlotHit* hist = DrumHistFor(fontId, instOrWave)) { + for (int slot = 0; slot < kDrumHistSlots; ++slot) + hist[slot].count.store(0, std::memory_order_relaxed); + } +} + +// ── Pack stack + sfontId resolution ─────────────────────────────────────── + +void MidiTranslator::SetPackLoadOrder(const std::vector& order) { + mPackLoadOrder = order; +} + +int MidiTranslator::PackRank(const std::string& pack) const { + // Higher index = later loaded = wins resolution. -1 if not loaded. + for (size_t i = 0; i < mPackLoadOrder.size(); i++) { + if (mPackLoadOrder[i] == pack) + return static_cast(i); + } + return -1; +} + +int16_t MidiTranslator::ResolveSfontIdFromCache(const std::string& pack, int16_t bank, int16_t program) const { + if (pack.empty() || program < 0) { + // Placeholder entries — synthetic sfontId=0 so they resolve and + // mute via the program<0 branch in ProcessNote. + return 0; + } + for (const auto& lp : mLoadedPresets) { + if (lp.packName == pack && lp.bank == bank && lp.program == program) { + return static_cast(lp.sfontId); + } + } + return -1; +} + +void MidiTranslator::RefreshEntrySfontIds(const std::vector& loadedPresets) { + // Cache the list so the per-mutation entry creation path can resolve + // sfontIds without waiting for the next pack-stack pass. + mLoadedPresets = loadedPresets; + // O(N*M) walk — both N (entries) and M (loaded presets) stay small in + // practice (a few hundred each). Build a quick lookup if this ever + // becomes hot. + for (auto& e : mEntries) { + e.sfontId = ResolveSfontIdFromCache(e.packName, e.bank, e.program); + } +} + +void MidiTranslator::RemoveModEntriesNotIn(const std::set& packNamesLoaded) { + // Erase ModSupplied entries whose pack isn't loaded anymore. We also + // clear any active-cache slots that pointed into them; the caller is + // expected to follow up with RecomputeAllActive() since indices shift. + auto newEnd = std::remove_if(mEntries.begin(), mEntries.end(), [&](const ConfigEntry& e) { + return e.source == EntrySource::ModSupplied && !packNamesLoaded.count(e.packName); + }); + if (newEnd != mEntries.end()) { + mEntries.erase(newEnd, mEntries.end()); + // Clear the active cache wholesale — indices may have shifted. + for (auto& row : mActiveEntryIdx) + for (auto& cell : row) + cell = -1; + } +} + +void MidiTranslator::RecomputeAllActive() { + for (auto& row : mActiveEntryIdx) + for (auto& cell : row) + cell = -1; + // Recompute only pairs that actually have entries — RecomputeActive is + // O(128 * entries-for-pair), so scanning all 64*256 cells would be + // wasteful. The distinct-pair set is small (a few hundred at most). + std::set> pairs; + for (const auto& e : mEntries) + if (BypassIndexValid(e.fontId, e.instOrWave)) + pairs.insert({ e.fontId, e.instOrWave }); + for (const auto& p : pairs) + RecomputeActive(p.first, p.second); +} + +void MidiTranslator::RecomputeActive(uint8_t fontId, int16_t instOrWave) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + + // Total order ranking entries for a semitone (drives both the per-semitone pick + // and the chain sort): 1) source -- user picks beat mod-supplied; 2) specificity + // -- narrower range beats wider; 3) pack load order -- later-loaded wins. + auto outranks = [this](int ai, int bi) { + const ConfigEntry& a = mEntries[ai]; + const ConfigEntry& b = mEntries[bi]; + int sa = (a.source == EntrySource::UserPicked) ? 1 : 0; + int sb = (b.source == EntrySource::UserPicked) ? 1 : 0; + if (sa != sb) + return sa > sb; + int wa = static_cast(a.noteHigh) - static_cast(a.noteLow); + int wb = static_cast(b.noteHigh) - static_cast(b.noteLow); + if (wa != wb) + return wa < wb; // narrower == more specific -> wins + return PackRank(a.packName) > PackRank(b.packName); + }; + + // Per-semitone winner: the top-ranked enabled+resolvable entry whose + // [noteLow,noteHigh] covers that engine slot. For an unsplit pair every semitone + // resolves to the same single entry (or none), collapsing the chain to length 1. + int winnerAt[128]; + for (int s = 0; s < 128; ++s) { + int best = -1; + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + if (e.fontId != fontId || e.instOrWave != instOrWave) + continue; + if (!e.enabled) + continue; + // Native/Mute splits don't reference a soundfont, so they qualify + // without a resolved sfontId; only Synth entries need one. + if (e.route == EntryRoute::Synth && e.sfontId < 0) + continue; + if (s < e.noteLow || s > e.noteHigh) + continue; + if (best < 0 || outranks(static_cast(idx), best)) + best = static_cast(idx); + } + winnerAt[s] = best; + } + + // Distinct winners, deduped so each entry is linked at most once (keeps the + // chain acyclic; the audio thread walks it by index). Sorted by the SAME + // priority order so the walk's "first covering entry wins" reproduces winnerAt + // even with overlapping ranges (e.g. a user split over a mod full-range): for + // any semitone the highest-priority covering winner sorts ahead of the rest. + std::vector winners; + for (int s = 0; s < 128; ++s) { + int w = winnerAt[s]; + if (w < 0) + continue; + if (std::find(winners.begin(), winners.end(), w) == winners.end()) + winners.push_back(w); + } + std::sort(winners.begin(), winners.end(), [&](int a, int b) { return outranks(a, b); }); + + // Reset this pair's links, relink the winners, then publish the head LAST (the + // single int16_t store) so a concurrent audio-thread walk sees either the intact + // old chain or the fully-built new one. The brief reset->relink window can at + // worst play one note native. + for (auto& e : mEntries) + if (e.fontId == fontId && e.instOrWave == instOrWave) + e.nextActiveSplit = -1; + for (size_t k = 0; k + 1 < winners.size(); ++k) + mEntries[winners[k]].nextActiveSplit = static_cast(winners[k + 1]); + mActiveEntryIdx[fontId][instOrWave] = winners.empty() ? -1 : static_cast(winners[0]); +} + +// ── Entry queries ───────────────────────────────────────────────────────── + +const ConfigEntry* MidiTranslator::GetActiveEntry(uint8_t fontId, int16_t instOrWave) const { + if (!BypassIndexValid(fontId, instOrWave)) + return nullptr; + int idx = mActiveEntryIdx[fontId][instOrWave]; + if (idx < 0 || idx >= static_cast(mEntries.size())) + return nullptr; + return &mEntries[idx]; +} +int MidiTranslator::GetActiveEntryIdx(uint8_t fontId, int16_t instOrWave) const { + if (!BypassIndexValid(fontId, instOrWave)) + return -1; + return mActiveEntryIdx[fontId][instOrWave]; +} + +void MidiTranslator::GetEntriesForPair(uint8_t fontId, int16_t instOrWave, std::vector& outIdx) const { + outIdx.clear(); + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + if (e.fontId == fontId && e.instOrWave == instOrWave) { + outIdx.push_back(static_cast(idx)); + } + } +} + +int MidiTranslator::FindEntry(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, + uint8_t noteLow) const { + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + if (e.fontId == fontId && e.instOrWave == instOrWave && e.packName == pack && e.program == program && + e.noteLow == noteLow) { + return static_cast(idx); + } + } + return -1; +} + +int MidiTranslator::CountSelectedEntries(uint8_t fontId, int16_t instOrWave) const { + int n = 0; + for (const auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave && e.selected) + n++; + } + return n; +} + +int MidiTranslator::FindOrCreateEntry(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, + int16_t bank, const std::string& presetName, EntrySource source, + uint8_t noteLow) { + int idx = FindEntry(fontId, instOrWave, pack, program, noteLow); + if (idx >= 0) + return idx; + if (mEntries.size() >= kMaxEntries) { + SPDLOG_WARN("[MidiTranslator] Entry pool full ({}); dropping new entry " + "for f={}, i={}, pack='{}', program={}", + kMaxEntries, fontId, instOrWave, pack, program); + return -1; + } + ConfigEntry e; + e.fontId = fontId; + e.instOrWave = instOrWave; + e.packName = pack; + e.program = program; + e.bank = bank; + e.presetName = presetName; + e.source = source; + e.noteLow = noteLow; // noteHigh stays 127 (full range) until a split sets it + // Resolve sfontId immediately so the entry is eligible for resolution on the + // next note; otherwise the filter (enabled && sfontId>=0) skips a fresh pick. + // ResolveSfontIdFromCache returns 0 for placeholder entries so they resolve and + // produce the silent NoteOn the user asked for. + e.sfontId = ResolveSfontIdFromCache(pack, bank, program); + mEntries.push_back(std::move(e)); + return static_cast(mEntries.size()) - 1; +} + +// ── UI row actions ──────────────────────────────────────────────────────── + +void MidiTranslator::PickPreset(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, + int16_t bank, const std::string& presetName) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + + // Capture the previous winner's pack so we know whether to clear its + // selected flag (same SoundFont as the new pick → user moved within + // the pack and the old entry is no longer "the one for this pack"). + std::string prevWinnerPack; + int prevWinnerIdx = mActiveEntryIdx[fontId][instOrWave]; + if (prevWinnerIdx >= 0 && prevWinnerIdx < static_cast(mEntries.size())) { + prevWinnerPack = mEntries[prevWinnerIdx].packName; + } + + // Disable every currently-enabled entry for this pair. + for (auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave && e.enabled) { + e.enabled = false; + } + } + + // Same-SoundFont rule: if the previous winner was from the new + // pick's pack, drop its selected flag (the user replaced it in-place). + if (!prevWinnerPack.empty() && prevWinnerPack == pack && prevWinnerIdx >= 0) { + ConfigEntry& prev = mEntries[prevWinnerIdx]; + if (prev.program != program) { + prev.selected = false; + } + } + + const size_t entryCountBefore = mEntries.size(); + int idx = FindOrCreateEntry(fontId, instOrWave, pack, program, bank, presetName, EntrySource::UserPicked); + if (idx < 0) + return; + ConfigEntry& e = mEntries[idx]; + // On a freshly created entry, seed the octave Shift from the engine sample's + // tuning so a substitute lands in the native octave by default; user-overridable. + // SOH [Enhancement] + if (mEntries.size() > entryCountBefore) { + e.transpose = static_cast(SuggestedTranspose(fontId, instOrWave, e.noteLow, e.noteHigh)); + } + // Preserve gain/transpose/effects when reusing; only program/bank/pack identify + // the entry. presetName updates so renamed presets surface. Re-resolve sfontId + // in case the bank changed on a reused entry (FindEntry keys on pack+program). + e.bank = bank; + e.presetName = presetName; + e.sfontId = ResolveSfontIdFromCache(pack, bank, program); + e.enabled = true; + e.selected = true; + e.lastEnabledSeq = mNextSeq++; + // source stays whatever it was (might be ModSupplied if the user + // clicked a mod-shipped entry; that's fine — picking promotes it to + // a selected entry which will be persisted). + if (e.source == EntrySource::ModSupplied) { + // The act of picking promotes a mod entry to user-owned so we + // persist customisations the user makes on top of it. + e.source = EntrySource::UserPicked; + } + RecomputeActive(fontId, instOrWave); +} + +void MidiTranslator::ClickNative(uint8_t fontId, int16_t instOrWave) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + bool hasModEntry = false; + for (auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave) { + if (e.source == EntrySource::ModSupplied) + hasModEntry = true; + e.enabled = false; + } + } + // A pack mapping ships enabled entries that reappear -- and, under source-aware + // resolution, win -- on every reload, so disabling our entries can't outlast it. + // Persist the Native choice as a user-owned full-range Native marker: it + // outranks the mod (UserPicked) and routes to the engine sample. Pairs with no + // mod entry need no marker -- a disabled user entry already persists "off". + if (hasModEntry) { + int idx = + FindOrCreateEntry(fontId, instOrWave, std::string(), -1, 0, std::string("Native"), EntrySource::UserPicked); + if (idx >= 0) { + ConfigEntry& m = mEntries[idx]; + m.route = EntryRoute::Native; + m.noteLow = 0; + m.noteHigh = 127; + m.enabled = true; + m.selected = true; + m.source = EntrySource::UserPicked; + m.lastEnabledSeq = mNextSeq++; + } + } + RecomputeActive(fontId, instOrWave); +} + +// A user Native marker is an empty-pack, full-range, route=Native entry created by +// ClickNative to persist "this pair plays native" over a mod that ships a preset. +static inline bool IsNativeMarker(const ConfigEntry& e) { + return e.route == EntryRoute::Native && e.packName.empty(); +} + +void MidiTranslator::ClickSynth(uint8_t fontId, int16_t instOrWave) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + + // Leaving Native: retire any user Native marker so it stops winning. The + // candidate searches below also skip it (deselected here, but the fallback + // ignores `selected`). + for (auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave && IsNativeMarker(e)) { + e.enabled = false; + e.selected = false; + } + } + + // Primary search: user-selected entries. + int bestIdx = -1; + uint32_t bestSeq = 0; + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + if (e.fontId != fontId || e.instOrWave != instOrWave) + continue; + if (!e.selected) + continue; + if (bestIdx < 0 || e.lastEnabledSeq >= bestSeq) { + bestIdx = static_cast(idx); + bestSeq = e.lastEnabledSeq; + } + } + // Fallback (option B): any disabled-but-resolvable entry for this + // pair. Covers the "mod ships a preset, user never picked, clicked + // Native, now wants it back" case. + if (bestIdx < 0) { + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + if (e.fontId != fontId || e.instOrWave != instOrWave) + continue; + if (IsNativeMarker(e)) + continue; // never restore the Native marker as a synth pick + if (e.sfontId < 0) + continue; + if (bestIdx < 0 || e.lastEnabledSeq >= bestSeq) { + bestIdx = static_cast(idx); + bestSeq = e.lastEnabledSeq; + } + } + } + if (bestIdx >= 0) { + ConfigEntry& e = mEntries[bestIdx]; + e.enabled = true; + e.lastEnabledSeq = mNextSeq++; + RecomputeActive(fontId, instOrWave); + return; + } + // Last resort: muted placeholder entry. packName=="" tells the resolver + // / UI that this is a synthetic "None" pick. + int idx = FindOrCreateEntry(fontId, instOrWave, std::string(), -1, 0, std::string("None"), EntrySource::UserPicked); + if (idx < 0) + return; + ConfigEntry& e = mEntries[idx]; + e.enabled = true; + e.selected = true; + e.lastEnabledSeq = mNextSeq++; + // Placeholder entries get sfontId=0 in RefreshEntrySfontIds so they + // participate in resolution; tag here as well in case Refresh hasn't + // run since the create. + e.sfontId = 0; + RecomputeActive(fontId, instOrWave); +} + +// Find a drum pair's current kit (pack/program) from any existing selected +// bank-128 slot entry, so re-running auto-split or toggling Synth preserves +// the user's kit choice. Falls back to the first loaded bank-128 preset, then +// to an empty pack (slots stay native until a kit is assigned). +void MidiTranslator::ResolveDrumKit(uint8_t fontId, int16_t instOrWave, std::string& outPack, int& outProgram) const { + for (const auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave && e.selected && e.bank == 128 && !e.packName.empty()) { + outPack = e.packName; + outProgram = e.program; + return; + } + } + for (const auto& lp : mLoadedPresets) { + if (lp.bank == 128) { + outPack = lp.packName; + outProgram = lp.program; + return; + } + } + outPack.clear(); + outProgram = 0; +} + +int MidiTranslator::FindSlotEntryIdx(uint8_t fontId, int16_t instOrWave, uint8_t slot) const { + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + if (e.fontId == fontId && e.instOrWave == instOrWave && e.selected && e.noteLow == slot) + return static_cast(idx); + } + return -1; +} + +const MidiTranslator::DrumSlotHit* MidiTranslator::DrumHistFor(uint8_t fontId, int16_t instOrWave) const { + if (fontId >= kMaxFontId || instOrWave < 0 || instOrWave >= kMaxInstOrWave) + return nullptr; + if (instOrWave < kDrumHistInst) + return mDrumSlotHits[fontId][instOrWave]; + int8_t pool = mForcedDrumPool[fontId][instOrWave]; + if (pool < 0 || pool >= kMaxForcedDrumPairs) + return nullptr; + return mForcedDrumHits[pool]; +} + +MidiTranslator::DrumSlotHit* MidiTranslator::DrumHistFor(uint8_t fontId, int16_t instOrWave) { + return const_cast(static_cast(this)->DrumHistFor(fontId, instOrWave)); +} + +int MidiTranslator::AllocForcedDrumPool() { + for (int p = 0; p < kMaxForcedDrumPairs; ++p) { + if (!mForcedDrumPoolUsed[p]) { + mForcedDrumPoolUsed[p] = true; + for (int s = 0; s < kDrumHistSlots; ++s) + mForcedDrumHits[p][s].count.store(0, std::memory_order_relaxed); + return p; + } + } + return -1; +} + +void MidiTranslator::SetForcedDrum(uint8_t fontId, int16_t instOrWave, bool forced) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (instOrWave < kDrumHistInst) + return; // 0/1 are intrinsic drum channels -- use SetDrumChannelSynth + + const bool wasForced = mForcedDrumPool[fontId][instOrWave] >= 0; + if (forced == wasForced) + return; + + if (forced) { + int pool = AllocForcedDrumPool(); + if (pool < 0) { + SPDLOG_WARN("[MidiTranslator] SetForcedDrum: pool full ({} pairs), " + "cannot flag font {} inst {}", + kMaxForcedDrumPairs, fontId, instOrWave); + return; // toggle doesn't stick; the pool is generously sized + } + // A full-range melodic entry would otherwise cover every slot and + // shadow the per-slot drum entries once discovered. Disable any enabled + // one so the pair starts from a clean native baseline (it stays in + // mEntries, deselected-but-present, so flipping back restores it). + for (auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave && e.enabled && e.noteLow == 0 && e.noteHigh == 127) + e.enabled = false; + } + mForcedDrumPool[fontId][instOrWave] = static_cast(pool); + } else { + int8_t pool = mForcedDrumPool[fontId][instOrWave]; + if (pool >= 0 && pool < kMaxForcedDrumPairs) + mForcedDrumPoolUsed[pool] = false; + mForcedDrumPool[fontId][instOrWave] = -1; + // Drop the per-slot drum entries so the pair returns to a clean melodic row. + // Disable + deselect rather than erase: erasing shifts the cached indices + // the audio thread reads. A deselected entry is ignored by resolution and + // dropped on the next save; re-forcing rediscovers them by key. + for (auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave && e.noteLow == e.noteHigh) { + e.enabled = false; + e.selected = false; + } + } + } + // Resolution gates on the flag at play time, but disabling a full-range + // entry (above) changes the chain, so recompute either way. + RecomputeActive(fontId, instOrWave); +} + +bool MidiTranslator::IsForcedDrum(uint8_t fontId, int16_t instOrWave) const { + if (fontId >= kMaxFontId || instOrWave < 0 || instOrWave >= kMaxInstOrWave) + return false; + return instOrWave >= kDrumHistInst && mForcedDrumPool[fontId][instOrWave] >= 0; +} + +void MidiTranslator::AutoSplitDrums(uint8_t fontId, int16_t instOrWave) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + const DrumSlotHit* hist = DrumHistFor(fontId, instOrWave); + if (!hist) + return; // no per-slot histogram (not a drum/SFX channel or forced pair) + + std::string kitPack; + int kitProgram = 0; + const int16_t kitBank = 128; + ResolveDrumKit(fontId, instOrWave, kitPack, kitProgram); + + // Map existing selected slot entries by their slot (noteLow) so a re-run + // updates them in place instead of duplicating (an entry created with a + // different/empty pack wouldn't be found by the (pack,program,noteLow) + // key, so we match on slot directly). + int bySlot[128]; + for (int s = 0; s < 128; ++s) + bySlot[s] = -1; + for (size_t idx = 0; idx < mEntries.size(); idx++) { + const ConfigEntry& e = mEntries[idx]; + // Only reuse existing single-slot entries; full-range whole-pair entries + // (noteLow 0..127) must not be hijacked into slot 0. + if (e.fontId == fontId && e.instOrWave == instOrWave && e.selected && e.noteLow == e.noteHigh && + e.noteLow < 128) + bySlot[e.noteLow] = static_cast(idx); + } + + // One single-slot entry per fired slot. The default GM percussion note is + // a stable per-slot spread (35 + slot, clamped) so each slot is audibly + // distinct; the user remaps via the Drum Sound combo (item 8.1 will + // auto-pick it later). An existing slot keeps its prior Drum Sound. + for (int s = 0; s < kDrumHistSlots; ++s) { + if (hist[s].count.load(std::memory_order_relaxed) == 0) + continue; + int idx = bySlot[s]; + const bool isNew = (idx < 0); + if (isNew) { + idx = FindOrCreateEntry(fontId, instOrWave, kitPack, static_cast(kitProgram), kitBank, + std::string("Drum Kit"), EntrySource::UserPicked, static_cast(s)); + if (idx < 0) + continue; + } + ConfigEntry& e = mEntries[idx]; + e.noteLow = static_cast(s); + e.noteHigh = static_cast(s); + e.packName = kitPack; + e.program = static_cast(kitProgram); + e.bank = kitBank; + if (e.fixedNote < 0) { + e.fixedNote = + static_cast(std::clamp(static_cast(kGmPercussionLo) + s, + static_cast(kGmPercussionLo), static_cast(kGmPercussionHi))); + } + e.route = EntryRoute::Synth; + e.source = EntrySource::UserPicked; + e.selected = true; + // Discovery only POPULATES the row. A freshly discovered slot defaults + // to Native (disabled) so switching the instrument to Synth doesn't + // suddenly blast a guessed GM kit; the user enables each slot by + // picking a sound. Existing slots keep their saved enabled state. + if (isNew) + e.enabled = false; + if (e.enabled) + e.lastEnabledSeq = mNextSeq++; + // No kit loaded -> unresolved (native) rather than mis-synthing on the + // empty-pack placeholder path (which would resolve to sfontId 0). + e.sfontId = kitPack.empty() ? -1 : ResolveSfontIdFromCache(kitPack, kitBank, kitProgram); + } + RecomputeActive(fontId, instOrWave); +} + +void MidiTranslator::AddDrumSlot(uint8_t fontId, int16_t instOrWave, uint8_t slot) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (slot >= kDrumHistSlots) + return; + // Drum-like pairs only: intrinsic drum/SFX channels or a forced-drum pair. + if (instOrWave != 0 && instOrWave != 1 && !IsForcedDrum(fontId, instOrWave)) + return; + + std::string kitPack; + int kitProgram = 0; + const int16_t kitBank = 128; + ResolveDrumKit(fontId, instOrWave, kitPack, kitProgram); + + int idx = FindSlotEntryIdx(fontId, instOrWave, slot); + if (idx < 0) { + idx = FindOrCreateEntry(fontId, instOrWave, kitPack, static_cast(kitProgram), kitBank, + std::string("Drum Kit"), EntrySource::UserPicked, slot); + if (idx < 0) + return; + } + ConfigEntry& e = mEntries[idx]; + e.noteLow = slot; + e.noteHigh = slot; + e.packName = kitPack; + e.program = static_cast(kitProgram); + e.bank = kitBank; + if (e.fixedNote < 0) { + e.fixedNote = + static_cast(std::clamp(static_cast(kGmPercussionLo) + slot, static_cast(kGmPercussionLo), + static_cast(kGmPercussionHi))); + } + e.route = EntryRoute::Synth; + e.source = EntrySource::UserPicked; + e.selected = true; + // Native by default (like discovery) so adding a slot never blasts a guessed + // GM kit; the user enables it by picking a Drum Sound. + e.enabled = false; + e.sfontId = kitPack.empty() ? -1 : ResolveSfontIdFromCache(kitPack, kitBank, kitProgram); + RecomputeActive(fontId, instOrWave); +} + +void MidiTranslator::SetDrumChannelSynth(uint8_t fontId, int16_t instOrWave, bool synth) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (instOrWave < 0 || instOrWave >= kDrumHistInst) + return; // master flag only meaningful for the drum/SFX channels + + // Master switch only: set the per-pair flag. We DON'T touch per-slot + // `enabled` here -- that is each slot's own Native/Synth state, preserved + // across instrument-mode toggles (so flipping to Native then back restores + // exactly what the slots were). + mDrumChannelSynth[fontId][instOrWave] = synth; + + if (synth) { + // Going Synth needs rows to edit. If none exist yet, discover them + // from the histogram (created Native by default -- see AutoSplitDrums). + bool hasSlot = false; + for (const auto& e : mEntries) { + if (e.fontId == fontId && e.instOrWave == instOrWave && e.selected && e.noteLow == e.noteHigh) { + hasSlot = true; + break; + } + } + if (!hasSlot) + AutoSplitDrums(fontId, instOrWave); + } + // Resolution gates on the flag at play time (ProcessNote), so no chain + // rebuild is required for the toggle itself; AutoSplitDrums recomputes if + // it ran. +} + +bool MidiTranslator::IsDrumChannelSynth(uint8_t fontId, int16_t instOrWave) const { + if (fontId >= kMaxFontId || instOrWave < 0 || instOrWave >= kDrumHistInst) + return false; + return mDrumChannelSynth[fontId][instOrWave]; +} + +void MidiTranslator::SetDrumKit(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, + const std::string& presetName) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + for (auto& e : mEntries) { + if (e.fontId != fontId || e.instOrWave != instOrWave || !e.selected) + continue; + e.packName = pack; + e.program = program; + e.bank = 128; + if (!presetName.empty()) + e.presetName = presetName; + e.source = EntrySource::UserPicked; + e.sfontId = ResolveSfontIdFromCache(pack, 128, program); + } + RecomputeActive(fontId, instOrWave); +} + +// ── Note-range split editing (melodic) ────────────────────────────────────── + +int MidiTranslator::SplitEntry(int idx, uint8_t atSemitone) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return -1; + // The cut must fall strictly inside the range so both halves are non-empty. + if (atSemitone <= mEntries[idx].noteLow || atSemitone > mEntries[idx].noteHigh) + return -1; + if (mEntries.size() >= kMaxEntries) + return -1; + ConfigEntry sib = mEntries[idx]; // copy preset/gain/effects/route/etc. + sib.noteLow = atSemitone; // sibling takes the upper half + sib.selected = true; + sib.source = EntrySource::UserPicked; + sib.lastEnabledSeq = mNextSeq++; + mEntries[idx].noteHigh = static_cast(atSemitone - 1); // idx keeps lower half + mEntries.push_back(std::move(sib)); // mEntries reserved -> no realloc, idx stays valid + int newIdx = static_cast(mEntries.size()) - 1; + RecomputeActive(mEntries[idx].fontId, mEntries[idx].instOrWave); + return newIdx; +} + +int MidiTranslator::SplitEntryEven(int idx, int parts) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return idx; + if (parts < 2) + return idx; + const int lo = mEntries[idx].noteLow; + const int hi = mEntries[idx].noteHigh; + const int span = hi - lo + 1; + if (span < parts) + parts = span; // can't make more ranges than semitones in the span + if (parts < 2) + return idx; + // Bisect off the top repeatedly: cut at the start of each part, then keep + // splitting the upper remainder. Boundaries are evenly spaced and strictly + // increasing (span >= parts), so each SplitEntry cut lands inside its range. + int cur = idx; + for (int k = 1; k < parts; ++k) { + int boundary = lo + static_cast((static_cast(span) * k) / parts); + int newIdx = SplitEntry(cur, static_cast(boundary)); + if (newIdx < 0) + break; + cur = newIdx; + } + return idx; +} + +void MidiTranslator::MergeWithNext(int idx) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + ConfigEntry& e = mEntries[idx]; + if (e.noteHigh >= 127) + return; // nothing above to merge + const int wantLow = static_cast(e.noteHigh) + 1; + int sib = -1; + for (size_t k = 0; k < mEntries.size(); k++) { + const ConfigEntry& c = mEntries[k]; + if (c.fontId == e.fontId && c.instOrWave == e.instOrWave && c.selected && + static_cast(c.noteLow) == wantLow) { + sib = static_cast(k); + break; + } + } + if (sib < 0) + return; + // Extend idx over the sibling's range, then retire the sibling (disable + + // deselect rather than erase -- erasing would shift every cached index and + // risk an audio-thread read mid-move; a deselected entry is dropped on the + // next save and ignored by resolution/UI). + e.noteHigh = mEntries[sib].noteHigh; + mEntries[sib].enabled = false; + mEntries[sib].selected = false; + RecomputeActive(e.fontId, e.instOrWave); +} + +void MidiTranslator::SetSplitBoundary(int lowerIdx, int upperIdx, uint8_t boundary) { + const int n = static_cast(mEntries.size()); + if (lowerIdx < 0 || lowerIdx >= n || upperIdx < 0 || upperIdx >= n) + return; + ConfigEntry& lo = mEntries[lowerIdx]; + ConfigEntry& hi = mEntries[upperIdx]; + // Keep both ranges non-empty: lo spans [lo.noteLow .. b], hi spans + // [b+1 .. hi.noteHigh], so b must sit in [lo.noteLow, hi.noteHigh - 1]. + int b = std::clamp(boundary, lo.noteLow, static_cast(hi.noteHigh) - 1); + lo.noteHigh = static_cast(b); + hi.noteLow = static_cast(b + 1); + RecomputeActive(lo.fontId, lo.instOrWave); +} + +void MidiTranslator::SetEntryNoteRange(int idx, uint8_t noteLow, uint8_t noteHigh) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + if (noteHigh < noteLow) + noteHigh = noteLow; + mEntries[idx].noteLow = noteLow; + mEntries[idx].noteHigh = noteHigh; + RecomputeActive(mEntries[idx].fontId, mEntries[idx].instOrWave); +} + +void MidiTranslator::SetEntryPreset(int idx, const std::string& pack, int16_t program, int16_t bank, + const std::string& presetName) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + ConfigEntry& e = mEntries[idx]; + e.packName = pack; + e.program = program; + e.bank = bank; + if (!presetName.empty()) + e.presetName = presetName; + e.route = EntryRoute::Synth; + e.fixedNote = -1; // melodic: derive the played pitch from the engine semitone + e.enabled = true; + e.selected = true; + e.source = EntrySource::UserPicked; + e.lastEnabledSeq = mNextSeq++; + e.sfontId = ResolveSfontIdFromCache(pack, bank, program); + RecomputeActive(e.fontId, e.instOrWave); +} + +void MidiTranslator::AutoSplitByEngineRanges(uint8_t fontId, int16_t instOrWave) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (instOrWave < kDrumHistInst) + return; // melodic only -- drums use AutoSplitDrums + + // The engine routes semitone < lo -> low sample, lo..hi -> normal, + // semitone > hi -> high. Mirror those boundaries. + InstrumentSampleSet names = GetInstrumentSampleNames(fontId, instOrWave); + if (!names.hasRange) + return; // no captured boundaries (older asset / mod font) + const uint8_t lo = names.rangeLo; + const uint8_t hi = names.rangeHi; + if (lo == 0 && hi >= 127) + return; // engine uses a single full range here -- nothing to split + + // Split the pair's active entry (its preset is duplicated across the + // ranges). With no active synth entry there's nothing to mirror. + int actIdx = mActiveEntryIdx[fontId][instOrWave]; + if (actIdx < 0 || actIdx >= static_cast(mEntries.size())) + return; + ConfigEntry tmpl = mEntries[actIdx]; // copy the preset/gain/effects/route + + // Repurpose the active entry as the normal range [lo, hi]. Each range maps to a + // distinct engine sample, so re-derive its octave Shift from that sample's tuning. + // SOH [Enhancement] + mEntries[actIdx].noteLow = lo; + mEntries[actIdx].noteHigh = hi; + mEntries[actIdx].transpose = static_cast(SuggestedTranspose(fontId, instOrWave, lo, hi)); + + auto addRange = [&](uint8_t rLo, uint8_t rHi) { + if (mEntries.size() >= kMaxEntries) + return; + ConfigEntry e = tmpl; + e.noteLow = rLo; + e.noteHigh = rHi; + e.transpose = static_cast(SuggestedTranspose(fontId, instOrWave, rLo, rHi)); + e.selected = true; + e.source = EntrySource::UserPicked; + e.lastEnabledSeq = mNextSeq++; + mEntries.push_back(std::move(e)); + }; + if (lo > 0) + addRange(0, static_cast(lo - 1)); // low sample range + if (hi < 127) + addRange(static_cast(hi + 1), 127); // high sample range + RecomputeActive(fontId, instOrWave); +} + +// ── Per-entry mutators ──────────────────────────────────────────────────── + +void MidiTranslator::SetEntryGain(int idx, float gain) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + mEntries[idx].gain = gain; +} +void MidiTranslator::SetEntryTranspose(int idx, int8_t semis) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + mEntries[idx].transpose = semis; +} +static inline int8_t ClampCcOrSentinel(int v) { + if (v < 0) + return -1; + if (v > 127) + return 127; + return static_cast(v); +} +void MidiTranslator::SetEntryReverb(int idx, int8_t cc) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + mEntries[idx].reverb = ClampCcOrSentinel(cc); +} +void MidiTranslator::SetEntryChorus(int idx, int8_t cc) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + mEntries[idx].chorus = ClampCcOrSentinel(cc); +} +void MidiTranslator::SetEntryFilterCutoff(int idx, int8_t cc) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + mEntries[idx].cutoff = ClampCcOrSentinel(cc); +} +void MidiTranslator::SetEntryFilterResonance(int idx, int8_t cc) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + mEntries[idx].q = ClampCcOrSentinel(cc); +} +void MidiTranslator::SetEntryFixedNote(int idx, int16_t note) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + // -1 = derive from semitone; otherwise a valid MIDI note. No chain + // recompute: fixedNote changes WHAT a winner plays, not WHICH wins. + mEntries[idx].fixedNote = (note < 0) ? -1 : static_cast(std::clamp(note, 0, 127)); +} +void MidiTranslator::SetEntryRoute(int idx, EntryRoute route) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + mEntries[idx].route = route; + // Route doesn't change chain membership today, but recompute keeps the + // cache authoritative if that ever changes; it's a cheap UI-thread call. + RecomputeActive(mEntries[idx].fontId, mEntries[idx].instOrWave); +} +void MidiTranslator::SetEntryEnabled(int idx, bool enabled) { + if (idx < 0 || idx >= static_cast(mEntries.size())) + return; + ConfigEntry& e = mEntries[idx]; + e.enabled = enabled; + if (enabled) + e.lastEnabledSeq = mNextSeq++; + RecomputeActive(e.fontId, e.instOrWave); +} +// ── Per-pair display name (row label) ───────────────────────────────────── + +std::string MidiTranslator::GetDisplayName(uint8_t fontId, int16_t instOrWave) const { + auto it = mDisplayName.find({ fontId, instOrWave }); + return it == mDisplayName.end() ? std::string{} : it->second; +} +void MidiTranslator::SetDisplayName(uint8_t fontId, int16_t instOrWave, const std::string& name) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (name.empty()) + mDisplayName.erase({ fontId, instOrWave }); + else + mDisplayName[{ fontId, instOrWave }] = name; +} + +// ── Session-only transient state ────────────────────────────────────────── + +bool MidiTranslator::IsTemporarilyMuted(uint8_t fontId, int16_t instOrWave) const { + return mTemporaryMute.count({ fontId, instOrWave }) > 0; +} +void MidiTranslator::SetTemporaryMute(uint8_t fontId, int16_t instOrWave, bool muted) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (muted) + mTemporaryMute.insert({ fontId, instOrWave }); + else + mTemporaryMute.erase({ fontId, instOrWave }); +} +void MidiTranslator::ClearAllTemporaryMutes() { + mTemporaryMute.clear(); +} +bool MidiTranslator::IsTemporarilySlotMuted(uint8_t fontId, int16_t instOrWave, uint8_t noteLow) const { + return mTemporarySlotMute.count({ fontId, instOrWave, noteLow }) > 0; +} +void MidiTranslator::SetTemporarySlotMute(uint8_t fontId, int16_t instOrWave, uint8_t noteLow, bool muted) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (muted) + mTemporarySlotMute.insert({ fontId, instOrWave, noteLow }); + else + mTemporarySlotMute.erase({ fontId, instOrWave, noteLow }); +} +void MidiTranslator::ClearAllTemporarySlotMutes() { + mTemporarySlotMute.clear(); +} + +float MidiTranslator::GetTemporaryVolume(uint8_t fontId, int16_t instOrWave) const { + auto it = mTemporaryVolume.find({ fontId, instOrWave }); + return it == mTemporaryVolume.end() ? 1.0f : it->second; +} +void MidiTranslator::SetTemporaryVolume(uint8_t fontId, int16_t instOrWave, float vol) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + if (vol == 1.0f) { + mTemporaryVolume.erase({ fontId, instOrWave }); + } else { + if (vol < 0.0f) + vol = 0.0f; + if (vol > 4.0f) + vol = 4.0f; + mTemporaryVolume[{ fontId, instOrWave }] = vol; + } +} +void MidiTranslator::ClearAllTemporaryVolumes() { + mTemporaryVolume.clear(); +} + +// ── MIDI channel pool ───────────────────────────────────────────────────── + +uint8_t MidiTranslator::GetChannelsInUse() const { + return mChannelsAllocated; +} + +uint32_t MidiTranslator::GetChannelReclaims() const { + return mChannelReclaims; +} + +uint8_t MidiTranslator::ReclaimIdleChannel(uint8_t exceptFontId, int16_t exceptInst) { + for (uint8_t ch = 0; ch < mChannelsAllocated; ++ch) { + ChannelOwner& o = mChannelOwner[ch]; + if (o.instOrWave < 0) + continue; // unowned (shouldn't happen without eager release) + if (o.fontId == exceptFontId && o.instOrWave == exceptInst) + continue; // never evict the pair asking for a channel + if (mSynthActiveByPair[o.fontId][o.instOrWave] != 0) + continue; // still sounding -- leave it alone + // Idle pair: drop its claim. The physical channel keeps its program + // and CC state, and so does mChannelState[ch], so the new owner's + // ProcessNote re-issues only what differs. No explicit reset needed. + mPairChannel[o.fontId][o.instOrWave] = 0xFF; + o.instOrWave = -1; + return ch; + } + return 0xFF; +} + +// 0xFF return = no channel available; caller routes the note to native. +uint8_t MidiTranslator::AllocateChannelForPair(uint8_t fontId, int16_t instOrWave) { + if (!BypassIndexValid(fontId, instOrWave)) + return 0xFF; + uint8_t& slot = mPairChannel[fontId][instOrWave]; + if (slot != 0xFF) + return slot; + + // Room to grow the pool: hand out the next channel. + if (mChannelsAllocated < kMaxMidiChannels) { + slot = mChannelsAllocated++; + mChannelOwner[slot] = { fontId, instOrWave }; + return slot; + } + + // Pool full: reclaim a channel from a pair that has gone quiet (a prior + // song's instruments). Active pairs keep theirs, so nothing sounding is + // cut. This is what stops the long-session "instruments stop playing" + // collapse -- new pairs get a real channel instead of sharing ch 0. + uint8_t reclaimed = ReclaimIdleChannel(fontId, instOrWave); + if (reclaimed != 0xFF) { + mChannelOwner[reclaimed] = { fontId, instOrWave }; + ++mChannelReclaims; + slot = reclaimed; + return slot; + } + + // Genuinely exhausted: all 64 channels are sounding distinct pairs right + // now. Collapsing onto channel 0 would hijack whatever instrument owns it + // and play this note with the wrong program, so signal failure instead and + // let the caller fall back to native. mPairChannel stays unassigned, so a + // later note retries once any pair goes quiet. + static bool sLoggedOverflow = false; + if (!sLoggedOverflow) { + SPDLOG_WARN("[MidiTranslator] All {} channels sounding at once; " + "routing overflow pair to native", + (int)kMaxMidiChannels); + sLoggedOverflow = true; + } + return 0xFF; +} + +void MidiTranslator::RecordDiscovery(uint8_t fontId, int16_t instOrWave, bool mapped) { + if (!BypassIndexValid(fontId, instOrWave)) + return; + const size_t bitIdx = static_cast(fontId) * kMaxInstOrWave + instOrWave; + const size_t wordIdx = bitIdx / 64; + const uint64_t mask = uint64_t(1) << (bitIdx & 63); + if (mSeenBits[wordIdx] & mask) + return; + mSeenBits[wordIdx] |= mask; + + int slot = mDiscoveredCount.load(std::memory_order_relaxed); + if (slot >= kMaxDiscovered) + return; + mDiscovered[slot] = { fontId, instOrWave, mapped }; + mDiscoveredCount.store(slot + 1, std::memory_order_release); +} + +// ── ResetAllOverrides + Persistence ─────────────────────────────────────── + +void MidiTranslator::ResetAllOverrides() { + mEntries.clear(); + for (auto& row : mActiveEntryIdx) + for (auto& cell : row) + cell = -1; + mDisplayName.clear(); + for (auto& row : mDrumChannelSynth) + for (auto& cell : row) + cell = false; + for (auto& row : mForcedDrumPool) + for (auto& cell : row) + cell = -1; + for (auto& used : mForcedDrumPoolUsed) + used = false; + // Channel allocation, discovery bits, and active-voice counters + // intentionally survive — they're runtime state, not overrides. +} + +// Route <-> JSON string. Synth is the default and is omitted on write. +static const char* RouteToString(EntryRoute r) { + switch (r) { + case EntryRoute::Native: + return "native"; + case EntryRoute::Mute: + return "mute"; + case EntryRoute::Synth: + break; + } + return "synth"; +} +static EntryRoute RouteFromString(const std::string& s) { + if (s == "native") + return EntryRoute::Native; + if (s == "mute") + return EntryRoute::Mute; + return EntryRoute::Synth; +} + +// Append the note-range split fields to a serialised entry, each omitted at its +// default so unsplit entries serialise compactly. Shared by SaveOverridesToFile +// and ExportPackMapping so the two writers can't drift. (noteLow is part of the +// entry key, so it is written whenever nonzero.) +static void WriteSplitFields(nlohmann::json& entry, const ConfigEntry& e) { + if (e.noteLow != 0) + entry["note_low"] = e.noteLow; + if (e.noteHigh != 127) + entry["note_high"] = e.noteHigh; + if (e.fixedNote >= 0) + entry["fixed_note"] = e.fixedNote; + if (e.route != EntryRoute::Synth) + entry["route"] = RouteToString(e.route); +} + +// Read the non-key split fields (noteHigh / fixedNote / route) onto an entry. +// Missing keys keep the entry's current values, matching how gain/transpose/ +// effects overlay. noteLow is applied separately via the FindOrCreateEntry +// key so distinct splits resolve to distinct entries. +static void ReadSplitFields(const nlohmann::json& entry, ConfigEntry& e) { + if (entry.contains("note_high")) { + int nh = entry.value("note_high", 127); + e.noteHigh = static_cast(std::clamp(nh, 0, 127)); + } + if (entry.contains("fixed_note")) { + int fn = entry.value("fixed_note", -1); + e.fixedNote = static_cast(std::clamp(fn, -1, 127)); + } + if (entry.contains("route")) + e.route = RouteFromString(entry.value("route", std::string("synth"))); +} + +bool MidiTranslator::SaveOverridesToFile(const std::string& path) const { + nlohmann::json j; + j["version"] = 2; + j["entries"] = nlohmann::json::array(); + + // Save criteria: an entry is persisted when the user has expressed + // intent in it (selected=true) OR the pair has a display name + // override. Mod-shipped entries the user never touched are NOT + // persisted — they reload from each pack's mapping.json next session. + for (const auto& e : mEntries) { + bool hasDisplayName = false; + auto dispIt = mDisplayName.find({ e.fontId, e.instOrWave }); + if (dispIt != mDisplayName.end() && !dispIt->second.empty()) + hasDisplayName = true; + + if (!e.selected && !hasDisplayName) + continue; + if (e.source != EntrySource::UserPicked) + continue; // safety + + nlohmann::json entry; + entry["fontId"] = e.fontId; + entry["instOrWave"] = e.instOrWave; + entry["pack"] = e.packName; + entry["program"] = e.program; + entry["bank"] = e.bank; + if (!e.presetName.empty()) + entry["preset_name"] = e.presetName; + if (e.gain != 0.0f) + entry["gain"] = e.gain; + if (e.transpose != 0) + entry["transpose"] = e.transpose; + if (e.reverb >= 0) + entry["reverb"] = e.reverb; + if (e.chorus >= 0) + entry["chorus"] = e.chorus; + if (e.cutoff >= 0) + entry["filter_cutoff"] = e.cutoff; + if (e.q >= 0) + entry["filter_q"] = e.q; + WriteSplitFields(entry, e); + entry["enabled"] = e.enabled; + entry["selected"] = e.selected; + if (hasDisplayName) + entry["display_name"] = dispIt->second; + j["entries"].push_back(std::move(entry)); + } + + // Display-name-only pairs (no matching selected entry) still need a + // home in the JSON so the label survives a restart. Write a stub + // entry with display_name set but no pack/program/etc. + for (const auto& kv : mDisplayName) { + if (kv.second.empty()) + continue; + bool alreadyEmitted = false; + for (const auto& e : mEntries) { + if (e.fontId == kv.first.first && e.instOrWave == kv.first.second && e.selected && + e.source == EntrySource::UserPicked) { + alreadyEmitted = true; + break; + } + } + if (alreadyEmitted) + continue; + nlohmann::json entry; + entry["fontId"] = kv.first.first; + entry["instOrWave"] = kv.first.second; + entry["display_name"] = kv.second; + j["entries"].push_back(std::move(entry)); + } + + // Per-pair drum channel mode (the per-instrument Native/Synth master). + // Separate from per-slot entries, so persisted as its own list of pairs + // currently in Synth mode (absent => Native). + j["drum_channels_synth"] = nlohmann::json::array(); + for (int f = 0; f < kMaxFontId; ++f) + for (int i = 0; i < kDrumHistInst; ++i) + if (mDrumChannelSynth[f][i]) + j["drum_channels_synth"].push_back({ { "fontId", f }, { "instOrWave", i } }); + + // Forced-drum ("Treat as drum") pairs: a per-pair list, mirroring the drum + // channel-mode list above. The histogram pool slot is runtime-only; only + // the flag (instOrWave >= kDrumHistInst) is persisted. + j["forced_drums"] = nlohmann::json::array(); + for (int f = 0; f < kMaxFontId; ++f) + for (int i = kDrumHistInst; i < kMaxInstOrWave; ++i) + if (mForcedDrumPool[f][i] >= 0) + j["forced_drums"].push_back({ { "fontId", f }, { "instOrWave", i } }); + + std::ofstream out(path); + if (!out.is_open()) { + SPDLOG_WARN("[MidiTranslator] SaveOverridesToFile: cannot open {}", path); + return false; + } + out << j.dump(2); + return out.good(); +} + +// Shared predicate for "ship this entry inside a pack mapping?". Same gate used by +// ExportPackMapping and CountExportableEntries so the preview count matches what +// gets written. Exports every entry currently enabled and resolvable for the pack +// (its effective mapping now), regardless of source or `selected`. PickPreset +// keeps at most one entry enabled per (pair, split), so this never double-emits. +static bool ExportEntryMatches(const ConfigEntry& e, const std::string& packNameFilter) { + if (!e.enabled) + return false; + if (e.program < 0) + return false; // None placeholder — not shippable + if (e.packName.empty()) + return false; + if (!packNameFilter.empty() && e.packName != packNameFilter) + return false; + return true; +} + +int MidiTranslator::CountExportableEntries(const std::string& packNameFilter) const { + int n = 0; + for (const auto& e : mEntries) + if (ExportEntryMatches(e, packNameFilter)) + ++n; + return n; +} + +std::string MidiTranslator::BuildPackMappingJson(const std::string& packNameFilter, int& outWritten) const { + nlohmann::json j; + j["version"] = 2; + // The pack name lives once, in this header. Entries do NOT repeat a + // per-entry "pack": the loader takes ownership from the pack's discovered + // name (and falls back to this header), so the field would be redundant. + if (!packNameFilter.empty()) + j["pack_name"] = packNameFilter; + j["entries"] = nlohmann::json::array(); + + int written = 0; + for (const auto& e : mEntries) { + if (!ExportEntryMatches(e, packNameFilter)) + continue; + + nlohmann::json entry; + entry["fontId"] = e.fontId; + entry["instOrWave"] = e.instOrWave; + entry["program"] = e.program; + entry["bank"] = e.bank; + if (!e.presetName.empty()) + entry["preset_name"] = e.presetName; + if (e.gain != 0.0f) + entry["gain"] = e.gain; + if (e.transpose != 0) + entry["transpose"] = e.transpose; + if (e.reverb >= 0) + entry["reverb"] = e.reverb; + if (e.chorus >= 0) + entry["chorus"] = e.chorus; + if (e.cutoff >= 0) + entry["filter_cutoff"] = e.cutoff; + if (e.q >= 0) + entry["filter_q"] = e.q; + WriteSplitFields(entry, e); + // Pack mapping consumers default enabled=true, selected=false at load + // time (ApplyOverridesFromString), so we omit both flags here — the + // file represents "this is the pack's recommended preset for the + // pair", not "this is currently picked by the user". + auto dispIt = mDisplayName.find({ e.fontId, e.instOrWave }); + if (dispIt != mDisplayName.end() && !dispIt->second.empty()) + entry["display_name"] = dispIt->second; + j["entries"].push_back(std::move(entry)); + ++written; + } + + // Forced-drum flags for the pairs that actually shipped a slot entry above, + // so a downstream user loads the pack with those pairs already routing as + // drums (the slot entries alone don't imply the master flag). Scoped to + // written pairs to avoid leaking unrelated forced pairs into a single-pack + // export. + nlohmann::json forced = nlohmann::json::array(); + for (int f = 0; f < kMaxFontId; ++f) { + for (int i = kDrumHistInst; i < kMaxInstOrWave; ++i) { + if (mForcedDrumPool[f][i] < 0) + continue; + bool shipped = false; + for (const auto& e : mEntries) { + if (e.fontId == f && e.instOrWave == i && ExportEntryMatches(e, packNameFilter)) { + shipped = true; + break; + } + } + if (shipped) + forced.push_back({ { "fontId", f }, { "instOrWave", i } }); + } + } + if (!forced.empty()) + j["forced_drums"] = std::move(forced); + + outWritten = written; + return j.dump(2); +} + +int MidiTranslator::ExportPackMapping(const std::string& path, const std::string& packNameFilter) const { + int written = 0; + std::string text = BuildPackMappingJson(packNameFilter, written); + + std::error_code ec; + auto parent = std::filesystem::path(path).parent_path(); + if (!parent.empty()) + std::filesystem::create_directories(parent, ec); + + std::ofstream out(path); + if (!out.is_open()) { + SPDLOG_WARN("[MidiTranslator] ExportPackMapping: cannot open {}", path); + return -1; + } + out << text; + if (!out.good()) { + SPDLOG_WARN("[MidiTranslator] ExportPackMapping: write failed for {}", path); + return -1; + } + SPDLOG_INFO("[MidiTranslator] ExportPackMapping: wrote {} entries to {}", written, path); + return written; +} + +bool MidiTranslator::ApplyOverridesFromString(const std::string& json, const std::string& packName) { + nlohmann::json j; + try { + j = nlohmann::json::parse(json); + } catch (const std::exception& e) { + SPDLOG_ERROR("[MidiTranslator] ApplyOverridesFromString: parse error: {}", e.what()); + return false; + } + // Owner resolution order: the caller's discovered name (authoritative) -> + // the file's "pack_name" header -> the per-entry "pack" (legacy files). + std::string headerPack = !packName.empty() ? packName : j.value("pack_name", std::string{}); + auto entries = j.value("entries", nlohmann::json::array()); + int applied = 0; + for (const auto& entry : entries) { + int fontId = entry.value("fontId", -1); + int instOrWave = entry.value("instOrWave", -1); + if (fontId < 0 || fontId >= kMaxFontId || instOrWave < 0 || instOrWave >= kMaxInstOrWave) + continue; + + if (entry.contains("display_name")) { + std::string dn = entry.value("display_name", std::string{}); + SetDisplayName(static_cast(fontId), static_cast(instOrWave), dn); + } + // A routing entry needs a program. Pairs without one are display-name + // -only stubs (handled just above) -> skip the routing path. The owner + // pack comes from the header/caller, falling back to a per-entry "pack" + // only for legacy files that still carry it. + if (!entry.contains("program")) + continue; + std::string pack = !headerPack.empty() ? headerPack : entry.value("pack", std::string{}); + if (pack.empty()) + continue; + int program = entry.value("program", -1); + int bank = entry.value("bank", 0); + if (program < -1 || program > 127) + continue; + if (bank < 0) + bank = 0; + if (bank > 255) + bank = 255; + std::string presetName = entry.value("preset_name", std::string{}); + // note_low is part of the entry key, so it must be resolved before + // find-or-create or distinct splits collapse into one entry. + uint8_t noteLow = static_cast(std::clamp(entry.value("note_low", 0), 0, 127)); + + int idx = FindOrCreateEntry(static_cast(fontId), static_cast(instOrWave), pack, + static_cast(program), static_cast(bank), presetName, + EntrySource::ModSupplied, noteLow); + if (idx < 0) + continue; + ConfigEntry& e = mEntries[idx]; + // Overlay fields. Missing keys keep current values. + e.bank = static_cast(bank); + if (!presetName.empty()) + e.presetName = presetName; + if (entry.contains("gain")) + e.gain = entry.value("gain", 0.0f); + if (entry.contains("transpose")) { + int t = entry.value("transpose", 0); + t = std::clamp(t, -127, 127); + e.transpose = static_cast(t); + } + if (entry.contains("reverb")) + e.reverb = ClampCcOrSentinel(entry.value("reverb", -1)); + if (entry.contains("chorus")) + e.chorus = ClampCcOrSentinel(entry.value("chorus", -1)); + if (entry.contains("filter_cutoff")) + e.cutoff = ClampCcOrSentinel(entry.value("filter_cutoff", -1)); + if (entry.contains("filter_q")) + e.q = ClampCcOrSentinel(entry.value("filter_q", -1)); + ReadSplitFields(entry, e); + // Pack mapping.json: enabled by default (mods publish active + // presets), selected stays false (user hasn't picked). + e.enabled = entry.value("enabled", true); + e.selected = entry.value("selected", false); + applied++; + } + if (j.contains("drum_channels_synth")) { + for (const auto& d : j["drum_channels_synth"]) { + int f = d.value("fontId", -1); + int i = d.value("instOrWave", -1); + if (f >= 0 && f < kMaxFontId && i >= 0 && i < kDrumHistInst) + mDrumChannelSynth[f][i] = true; + } + } + if (j.contains("forced_drums")) { + for (const auto& d : j["forced_drums"]) { + int f = d.value("fontId", -1); + int i = d.value("instOrWave", -1); + if (f >= 0 && f < kMaxFontId && i >= kDrumHistInst && i < kMaxInstOrWave) + SetForcedDrum(static_cast(f), static_cast(i), true); + } + } + SPDLOG_INFO("[MidiTranslator] ApplyOverridesFromString: applied {} entries", applied); + return true; +} + +bool MidiTranslator::ApplyOverridesFromFile(const std::string& path) { + std::ifstream in(path); + if (!in.is_open()) { + SPDLOG_INFO("[MidiTranslator] ApplyOverridesFromFile: no file at {} (first run?)", path); + return false; + } + nlohmann::json j; + try { + in >> j; + } catch (const std::exception& e) { + SPDLOG_ERROR("[MidiTranslator] ApplyOverridesFromFile: parse error: {}", e.what()); + return false; + } + int version = j.value("version", 1); + auto entries = j.value("entries", nlohmann::json::array()); + int applied = 0; + for (const auto& entry : entries) { + int fontId = entry.value("fontId", -1); + int instOrWave = entry.value("instOrWave", -1); + if (fontId < 0 || fontId >= kMaxFontId || instOrWave < 0 || instOrWave >= kMaxInstOrWave) + continue; + + if (entry.contains("display_name")) { + std::string dn = entry.value("display_name", std::string{}); + SetDisplayName(static_cast(fontId), static_cast(instOrWave), dn); + } + if (!entry.contains("pack")) + continue; + std::string pack = entry.value("pack", std::string{}); + int program = entry.value("program", -1); + int bank = entry.value("bank", 0); + if (program < -1 || program > 127) + continue; + if (bank < 0) + bank = 0; + if (bank > 255) + bank = 255; + std::string presetName = entry.value("preset_name", std::string{}); + // note_low is part of the entry key, so it must be resolved before + // find-or-create or distinct splits collapse into one entry. + uint8_t noteLow = static_cast(std::clamp(entry.value("note_low", 0), 0, 127)); + + int idx = FindOrCreateEntry(static_cast(fontId), static_cast(instOrWave), pack, + static_cast(program), static_cast(bank), presetName, + EntrySource::UserPicked, noteLow); + if (idx < 0) + continue; + ConfigEntry& e = mEntries[idx]; + e.bank = static_cast(bank); + if (!presetName.empty()) + e.presetName = presetName; + if (entry.contains("gain")) + e.gain = entry.value("gain", 0.0f); + if (entry.contains("transpose")) { + int t = entry.value("transpose", 0); + t = std::clamp(t, -127, 127); + e.transpose = static_cast(t); + } + if (entry.contains("reverb")) + e.reverb = ClampCcOrSentinel(entry.value("reverb", -1)); + if (entry.contains("chorus")) + e.chorus = ClampCcOrSentinel(entry.value("chorus", -1)); + if (entry.contains("filter_cutoff")) + e.cutoff = ClampCcOrSentinel(entry.value("filter_cutoff", -1)); + if (entry.contains("filter_q")) + e.q = ClampCcOrSentinel(entry.value("filter_q", -1)); + ReadSplitFields(entry, e); + // File overlay = user file. A missing key defaults to a user pick: + // enabled+selected unless the file says otherwise. Older files without + // these keys migrate to enabled, selected user picks. + e.enabled = entry.value("enabled", true); + e.selected = entry.value("selected", true); + // Promote to UserPicked even if a mod entry already exists for this key; + // the user file is the source of truth for source attribution. + e.source = EntrySource::UserPicked; + applied++; + } + if (j.contains("drum_channels_synth")) { + for (const auto& d : j["drum_channels_synth"]) { + int f = d.value("fontId", -1); + int i = d.value("instOrWave", -1); + if (f >= 0 && f < kMaxFontId && i >= 0 && i < kDrumHistInst) + mDrumChannelSynth[f][i] = true; + } + } + if (j.contains("forced_drums")) { + for (const auto& d : j["forced_drums"]) { + int f = d.value("fontId", -1); + int i = d.value("instOrWave", -1); + if (f >= 0 && f < kMaxFontId && i >= kDrumHistInst && i < kMaxInstOrWave) + SetForcedDrum(static_cast(f), static_cast(i), true); + } + } + SPDLOG_INFO("[MidiTranslator] ApplyOverridesFromFile: applied {} entries from {} (v{})", applied, path, version); + return true; +} + +// ── ProcessNote ─────────────────────────────────────────────────────────── + +bool MidiTranslator::ProcessNote(int noteIndex, float freqScale, float velocity, uint8_t pan, float channelVolume, + uint8_t fontId, int16_t instOrWave, uint8_t semitone, bool isFinished, + uint8_t channelIdx, float resampleRate, float pitchBend) { + (void)resampleRate; + (void)channelIdx; + + if (noteIndex < 0 || noteIndex >= kMaxNotes) + return false; + + auto synth = SOH::MidiSynthManager::Instance().GetActiveSynth(); + if (!synth) + return false; + + NoteTranslatorState& state = mNoteState[noteIndex]; + + // Discovery uses GetGmPreset purely as a "did the built-in GM table + // know about this pair?" hint for the UI rows. Resolution itself runs + // off mActiveEntryIdx now. + GmPreset builtinGm = GetGmPreset(fontId, instOrWave); + RecordDiscovery(fontId, instOrWave, builtinGm.program != kUnmapped); + + // DEBUG: per-pair stats. Detect a fresh NoteOn (Idle → playing transition); + // record the engine semitone. Route counters are incremented at the + // dispatch decision below — guarded by `wasIdleStart` so continuation + // frames of the same note don't double-count. + const bool wasIdleStart = + BypassIndexValid(fontId, instOrWave) && state.kind == SlotKind::Idle && velocity > 0.0f && !isFinished; + if (wasIdleStart) { + auto& dbg = mDebugStats[fontId][instOrWave]; + dbg.noteOns.fetch_add(1, std::memory_order_relaxed); + dbg.lastSemitone = semitone; + // Drum/SFX slot histogram: on those channels `semitone` is a slot + // index, so this is the per-slot fire count the drum auto-split reads. + // For a forced-drum melodic pair `semitone` is a real pitch, but the + // drum UI treats each distinct incoming value as a slot all the same, so + // the same histogram (a pool slot, via DrumHistFor) drives discovery. + if (semitone < kDrumHistSlots) { + if (DrumSlotHit* hist = DrumHistFor(fontId, instOrWave)) + hist[semitone].count.fetch_add(1, std::memory_order_relaxed); + } + } + // Helper for the route-decision counters below. Only counts on a fresh + // NoteOn so per-frame continuation calls don't inflate the totals. + auto bumpRoute = [&](std::atomic DebugSlot::*counter) { + if (wasIdleStart && BypassIndexValid(fontId, instOrWave)) + (mDebugStats[fontId][instOrWave].*counter).fetch_add(1, std::memory_order_relaxed); + }; + DBG_LOG(fontId, instOrWave, semitone, freqScale, builtinGm.program != kUnmapped, builtinGm.bank, builtinGm.program); + + auto retireSlot = [&]() { + if (state.kind == SlotKind::Synth) { + synth->NoteOff(state.channel, state.midiNote); + if (BypassIndexValid(state.pairFontId, state.pairInstOrWave) && + mSynthActiveByPair[state.pairFontId][state.pairInstOrWave] > 0) { + mSynthActiveByPair[state.pairFontId][state.pairInstOrWave]--; + } + DecEntryActive(true, state.activeEntryIdx); + } else if (state.kind == SlotKind::Native) { + if (BypassIndexValid(state.pairFontId, state.pairInstOrWave) && + mNativeActiveByPair[state.pairFontId][state.pairInstOrWave] > 0) { + mNativeActiveByPair[state.pairFontId][state.pairInstOrWave]--; + } + DecEntryActive(false, state.activeEntryIdx); + } + state.kind = SlotKind::Idle; + state.pairInstOrWave = -1; + state.activeEntryIdx = -1; + }; + + // entryIdx attributes the native note to a split row (route=Native) so its + // row lights up; -1 for the plain "no entry covers this" fall-through. + auto adoptNative = [&](int entryIdx) { + if (state.kind != SlotKind::Native || state.pairFontId != fontId || state.pairInstOrWave != instOrWave || + state.activeEntryIdx != entryIdx) { + retireSlot(); + state.kind = SlotKind::Native; + state.pairFontId = fontId; + state.pairInstOrWave = instOrWave; + state.activeEntryIdx = static_cast(entryIdx); + if (BypassIndexValid(fontId, instOrWave) && mNativeActiveByPair[fontId][instOrWave] < 255) { + mNativeActiveByPair[fontId][instOrWave]++; + } + IncEntryActive(false, entryIdx); + } + }; + + // Transient mute (Solo button) silences BOTH paths. + if (mTemporaryMute.count({ fontId, instOrWave })) { + retireSlot(); + return true; + } + + // Drum-like routing covers the intrinsic drum/SFX channels (instOrWave 0/1) + // and any pair the user flagged "Treat as drum". For both, the incoming + // `semitone` indexes a slot row whose fixedNote picks the played sound. + const bool isForcedDrum = IsForcedDrum(fontId, instOrWave); + const bool drumLike = (instOrWave == 0 || instOrWave == 1) || isForcedDrum; + + // Per-slot transient mute for drum-like pairs, BEFORE resolution: a slot's + // semitone IS its noteLow, so this catches native slots too (which skip the + // post-resolution check by falling through). Melodic ranges are still + // handled post-resolution by the covering entry's noteLow. + if (drumLike && semitone < 128 && + mTemporarySlotMute.count({ fontId, instOrWave, static_cast(semitone) })) { + retireSlot(); + return true; + } + + // Engine drum (instOrWave==0) and SFX (instOrWave==1): the `semitone` byte is a + // slot index (Audio_GetDrum / Audio_GetSfx), not a chromatic pitch. The per- + // instrument master mode gates the channel: in Native mode every slot plays the + // engine drum; only in Synth mode does per-slot resolution decide synth vs native. + if ((instOrWave == 0 || instOrWave == 1) && !mDrumChannelSynth[fontId][instOrWave]) { + int nativeAttrib = FindSlotEntryIdx(fontId, instOrWave, static_cast(semitone & 0x7F)); + if (isFinished || velocity <= 0.0f) + retireSlot(); + else + adoptNative(nativeAttrib); + bumpRoute(&DebugSlot::routedNative); + return false; + } + + // ── Resolution: walk the active-split chain ───────────────────────── + if (!BypassIndexValid(fontId, instOrWave)) { + if (isFinished || velocity <= 0.0f) + retireSlot(); + else + adoptNative(-1); + // bumpRoute is a no-op outside the bypass range — fine. + return false; + } + // Find the entry whose engine-semitone range covers this note. The chain is + // sorted by resolution priority, so the FIRST covering entry is the winner even + // when ranges overlap (RecomputeActive links each entry at most once -> acyclic); + // an unsplit pair has a length-1 chain so this is the same single lookup as before. + int activeIdx = mActiveEntryIdx[fontId][instOrWave]; + while (activeIdx >= 0 && activeIdx < static_cast(mEntries.size())) { + const ConfigEntry& cand = mEntries[activeIdx]; + if (semitone >= cand.noteLow && semitone <= cand.noteHigh) + break; + activeIdx = cand.nextActiveSplit; + } + if (activeIdx < 0 || activeIdx >= static_cast(mEntries.size())) { + // No enabled entry covers this slot → native plays. For a drum-like + // slot, attribute the native note to its slot row (the disabled entry) + // so the row's activity tint lights even in Native mode. + int nativeAttrib = -1; + if (drumLike) + nativeAttrib = FindSlotEntryIdx(fontId, instOrWave, static_cast(semitone & 0x7F)); + // A slot explicitly set to None silences even when the channel is in + // Native mode (its entry is disabled, so it lands here, not in the + // route==Mute branch below). This is how "mute note 0 while the rest + // play native" works. + if (nativeAttrib >= 0 && mEntries[nativeAttrib].route == EntryRoute::Mute) { + retireSlot(); + bumpRoute(&DebugSlot::routedMute); + return true; + } + if (isFinished || velocity <= 0.0f) + retireSlot(); + else + adoptNative(nativeAttrib); + bumpRoute(&DebugSlot::routedNative); + return false; + } + const ConfigEntry& e = mEntries[activeIdx]; + + // Per-split transient mute (Solo/Mute on an individual drum slot or melodic + // range). Silences both paths for this slot, like the pair-level mute. + if (mTemporarySlotMute.count({ fontId, instOrWave, e.noteLow })) { + retireSlot(); + return true; + } + + // Native-route split: this entry won its range but the audible path is + // the engine sample (a range the author wants to keep native while a + // sibling range synths). First-class winner, not the "no entry" fall- + // through, so a wider synth split can't shadow it. + if (e.route == EntryRoute::Native) { + if (isFinished || velocity <= 0.0f) + retireSlot(); + else + adoptNative(activeIdx); + bumpRoute(&DebugSlot::routedNative); + return false; + } + + // Placeholder ("None") / Mute entry: explicit user intent to mute the + // synth path while not letting native sneak in. Same return value as the + // Solo-button mute — true tells the C hook to suppress the engine. + if (e.route == EntryRoute::Mute || e.program < 0) { + retireSlot(); + bumpRoute(&DebugSlot::routedMute); + return true; + } + + const uint8_t gmBank = static_cast(e.bank); + const uint8_t gmProgram = static_cast(e.program); + const int16_t pinSfont = e.sfontId; + + // GM percussion (bank 128) now synths: the entry's range claims the + // engine drum slot and `fixedNote` (below) picks the GM percussion note + // to fire. ProgramSelect(bank=128) sets the channel to drum type, so the + // per-pair channel allocator handles it like any other preset. + + // Per-pair channel allocation — same channel for the life of a pair + // so per-pair effect CCs survive across notes. + uint8_t targetChannel = AllocateChannelForPair(fontId, instOrWave); + if (targetChannel == 0xFF) { + // Channel pool momentarily exhausted (all 64 sounding) -> native, so + // the instrument still plays instead of corrupting another channel. + if (isFinished || velocity <= 0.0f) + retireSlot(); + else + adoptNative(-1); + bumpRoute(&DebugSlot::routedNative); + return false; + } + + // Output MIDI note. A fixedNote (>= 0) pins it directly -- a GM percussion + // note for a bank-128 entry, or a tuned pitch -- because on a drum slot + // the engine `semitone` is a slot index, not a playable pitch. Melodic + // entries (fixedNote < 0) derive it from semitone + offset + transpose. + const bool hasFixedNote = (e.fixedNote >= 0); + int transpose = static_cast(e.transpose); + int midiRaw = hasFixedNote ? static_cast(e.fixedNote) + : static_cast(semitone) + kEngineSemitoneToMidiOffset + transpose; + uint8_t midiNote = static_cast(std::clamp(midiRaw, 0, 127)); + // A pinned note must not ride the engine's per-sample resampling bend -- + // for a drum slot that ratio is computed against a slot index and is + // meaningless -- so neutralise the wheel for fixed-note entries. + const float effectiveBend = hasFixedNote ? 1.0f : pitchBend; + + uint16_t preset = (static_cast(gmBank) << 8) | gmProgram; + ChannelState& chState = mChannelState[targetChannel]; + + if (isFinished || velocity <= 0.0f) { + retireSlot(); + return true; + } + + // ── ProgramSelect (pinned to entry's sfontId) ──────────────────────── + if (preset != chState.lastPreset || pinSfont != chState.lastPinSfont) { + bool ok = synth->ProgramSelect(targetChannel, pinSfont, gmBank, gmProgram); + if (!ok) { + // Pin failed mid-session — fall back to native for this note. + retireSlot(); + adoptNative(-1); + bumpRoute(&DebugSlot::routedNative); + return false; + } + chState.lastPreset = preset; + chState.lastPinSfont = pinSfont; + } + + uint8_t cc7val = static_cast(std::clamp(channelVolume * 127.0f, 0.0f, 127.0f)); + if (cc7val != chState.lastVolumeCC) { + synth->ControlChange(targetChannel, 7, static_cast(cc7val) << 7); + chState.lastVolumeCC = cc7val; + } + + uint8_t cc10val = static_cast(pan & 0x7F); + if (cc10val != chState.lastPanCC) { + synth->ControlChange(targetChannel, 10, static_cast(cc10val) << 7); + chState.lastPanCC = cc10val; + } + + auto resolveEffectCc = [](int8_t ovr, uint8_t fallback) -> uint8_t { + return (ovr >= 0) ? static_cast(ovr) : fallback; + }; + uint8_t reverbVal = resolveEffectCc(e.reverb, 0); + uint8_t chorusVal = resolveEffectCc(e.chorus, 0); + uint8_t cutoffVal = resolveEffectCc(e.cutoff, 64); + uint8_t qVal = resolveEffectCc(e.q, 64); + if (reverbVal != chState.lastReverbCC) { + synth->ControlChange(targetChannel, 91, static_cast(reverbVal) << 7); + chState.lastReverbCC = reverbVal; + } + if (chorusVal != chState.lastChorusCC) { + synth->ControlChange(targetChannel, 93, static_cast(chorusVal) << 7); + chState.lastChorusCC = chorusVal; + } + if (cutoffVal != chState.lastCutoffCC) { + synth->ControlChange(targetChannel, 74, static_cast(cutoffVal) << 7); + chState.lastCutoffCC = cutoffVal; + } + if (qVal != chState.lastQCC) { + synth->ControlChange(targetChannel, 71, static_cast(qVal) << 7); + chState.lastQCC = qVal; + } + chState.inited = true; + + // ── Velocity shaping ── + float entryGain = e.gain; + if (entryGain == 0.0f) + entryGain = 1.0f; + float tempVol = GetTemporaryVolume(fontId, instOrWave); + float shaped = std::clamp(sqrtf(std::max(0.0f, velocity)) * mGlobalGain * entryGain * tempVol, 0.0f, 1.0f); + + uint8_t noteOnVel; + uint8_t cc11val; + if (mSynthMode == SynthMode::Enhanced) { + noteOnVel = static_cast(shaped * 127.0f); + cc11val = 127; + } else { + noteOnVel = kFixedNoteOnVelocity; + cc11val = static_cast(shaped * 127.0f); + } + synth->ControlChange(targetChannel, 11, static_cast(cc11val) << 7); + + // `pitchBend` is the engine's per-note pitch deviation from the nominal note, as + // a frequency ratio (1.0 = no bend). audio_playback.c already stripped the non- + // pitch factors (sample tuning, resampleRate, and the note itself, which + // FluidSynth plays as midiNote), so hand the ratio straight to the synth. + + if (state.kind != SlotKind::Synth || state.pairFontId != fontId || state.pairInstOrWave != instOrWave || + midiNote != state.midiNote) { + retireSlot(); + // Drums: cut any voice already sounding this percussion note on the shared + // channel before retriggering. Percussion samples are one-shot (FluidSynth + // ignores NoteOff), so rapid hits would otherwise stack voices and overflow + // the voice ring. Melodic notes (no fixedNote) keep their polyphony intact. + if (hasFixedNote) + synth->NoteOff(targetChannel, midiNote); + synth->NoteOnPitchFactor(targetChannel, midiNote, noteOnVel, effectiveBend); + state.kind = SlotKind::Synth; + state.channel = targetChannel; + state.midiNote = midiNote; + state.pairFontId = fontId; + state.pairInstOrWave = instOrWave; + state.activeEntryIdx = static_cast(activeIdx); + state.lastFreqScale = freqScale; + if (mSynthActiveByPair[fontId][instOrWave] < 255) { + mSynthActiveByPair[fontId][instOrWave]++; + } + IncEntryActive(true, activeIdx); + bumpRoute(&DebugSlot::routedSynth); + return true; + } + + if (!hasFixedNote && fabsf(freqScale - state.lastFreqScale) > 1e-6f) { + synth->PitchBendFactor(state.channel, pitchBend); + state.lastFreqScale = freqScale; + } + return true; +} + +void MidiTranslator::NoteDisabled(int noteIndex) { + if (noteIndex < 0 || noteIndex >= kMaxNotes) + return; + NoteTranslatorState& state = mNoteState[noteIndex]; + if (state.kind == SlotKind::Synth) { + auto synth = SOH::MidiSynthManager::Instance().GetActiveSynth(); + if (synth) + synth->NoteOff(state.channel, state.midiNote); + if (BypassIndexValid(state.pairFontId, state.pairInstOrWave) && + mSynthActiveByPair[state.pairFontId][state.pairInstOrWave] > 0) { + mSynthActiveByPair[state.pairFontId][state.pairInstOrWave]--; + } + DecEntryActive(true, state.activeEntryIdx); + } else if (state.kind == SlotKind::Native) { + if (BypassIndexValid(state.pairFontId, state.pairInstOrWave) && + mNativeActiveByPair[state.pairFontId][state.pairInstOrWave] > 0) { + mNativeActiveByPair[state.pairFontId][state.pairInstOrWave]--; + } + DecEntryActive(false, state.activeEntryIdx); + } + state.kind = SlotKind::Idle; + state.pairInstOrWave = -1; + state.activeEntryIdx = -1; +} + +} // namespace SOH + +// ─── C-linkage entry points called from audio_playback.c ─────────────────── + +extern "C" { + +bool SOH_MidiTranslator_ProcessNote(int noteIndex, float freqScale, float velocity, uint8_t pan, float channelVolume, + uint8_t fontId, int16_t instOrWave, uint8_t semitone, bool isFinished, + uint8_t channelIdx, float resampleRate, float pitchBend) { + return SOH::MidiTranslator::Instance().ProcessNote(noteIndex, freqScale, velocity, pan, channelVolume, fontId, + instOrWave, semitone, isFinished, channelIdx, resampleRate, + pitchBend); +} + +void SOH_MidiTranslator_NoteDisabled(int noteIndex) { + SOH::MidiTranslator::Instance().NoteDisabled(noteIndex); +} + +void SOH_MidiTranslator_Reset() { + SOH::MidiTranslator::Instance().Reset(); +} + +} // extern "C" diff --git a/soh/soh/Enhancements/audio/MidiTranslator.h b/soh/soh/Enhancements/audio/MidiTranslator.h new file mode 100644 index 00000000000..73c55e848cf --- /dev/null +++ b/soh/soh/Enhancements/audio/MidiTranslator.h @@ -0,0 +1,557 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace SOH { + +// Per-channel state tracked across notes on the same MIDI channel. +// 0xFF / 0xFFFF sentinels mean "never sent" so the first NoteOn always +// emits a fresh ControlChange/ProgramChange. +struct ChannelState { + bool inited = false; + uint16_t lastPreset = 0xFFFF; // last ProgramChange value (bank<<8|prog) + int16_t lastPinSfont = -1; // last ProgramSelect sfontId; -1 = unpinned + uint8_t lastVolumeCC = 0xFF; // last CC 7 (channel volume) sent, 7-bit + uint8_t lastPanCC = 0xFF; // last CC 10 (pan) sent, 7-bit + uint8_t lastReverbCC = 0xFF; // CC 91 (reverb send) + uint8_t lastChorusCC = 0xFF; // CC 93 (chorus send) + uint8_t lastCutoffCC = 0xFF; // CC 74 (low-pass filter cutoff) + uint8_t lastQCC = 0xFF; // CC 71 (low-pass filter resonance) +}; + +// Which path a translator slot is currently routing audio through. Slots +// transition Idle -> (Synth | Native) on first sight and back to Idle on +// NoteDisabled. +enum class SlotKind : uint8_t { + Idle = 0, + Synth = 1, + Native = 2, +}; + +// Per-note state tracked between Audio_ProcessNotes calls. +struct NoteTranslatorState { + SlotKind kind = SlotKind::Idle; + uint8_t channel = 0; + uint8_t midiNote = 0; + uint8_t pairFontId = 0; + int16_t pairInstOrWave = -1; + float lastFreqScale = 0.0f; + // Entry this note resolved to, so retire/NoteDisabled can decrement the + // right per-entry active counter. -1 = native fall-through (no entry). + int16_t activeEntryIdx = -1; +}; + +// Overall translator behaviour, set from AudioEditor and read by ProcessNote. +enum class SynthMode : uint8_t { + Authentic = 0, // richer modulators + console-era reverb + Enhanced = 1, // stock SF modulators + low default reverb +}; + +// One entry per (fontId, instOrWave) pair the translator has actually +// seen. mapped indicates the GM mapping table had a non-kUnmapped value +// (UI hint only since the new resolution model doesn't use the table). +struct DiscoveredPair { + uint8_t fontId; + int16_t instOrWave; + bool mapped; +}; + +// Where a ConfigEntry came from. Decides persistence eligibility and +// whether the entry is removed when its pack is unloaded. +enum class EntrySource : uint8_t { + UserPicked = 0, // From fluidsynth_overrides.json or a UI pick + ModSupplied = 1, // From a pack mapping.json; not persisted, removed on + // pack unload +}; + +// Where a resolved (split) entry sends its notes once it wins resolution. +// Synth is the default — the entry references a (pack, program) FluidSynth +// plays. Native makes the entry a first-class winner whose audible path is +// the engine sample (a split range that should keep the native instrument). +// Mute is the silent "None" placeholder scoped to the range. Native vs Mute +// both lack a pack/program, so the route field is what distinguishes them. +enum class EntryRoute : uint8_t { + Synth = 0, + Native = 1, + Mute = 2, +}; + +// One row in the configuration. Multiple entries can share +// (fontId, instOrWave); each represents a distinct candidate keyed by +// (pack, program). Resolution at play time picks the enabled+resolvable +// candidate whose pack is last in the load order. +// +// IMPORTANT: the audio thread reads primitive fields of this struct via +// mEntries[mActiveEntryIdx[f][i]] without locking. mEntries is reserved +// at construction so push_back never reallocates. Strings are NOT touched +// from the audio thread. +struct ConfigEntry { + uint8_t fontId = 0; + int16_t instOrWave = -1; + std::string packName; // "" = placeholder ("None") entry + int16_t program = -1; // -1 = placeholder (no audible output) + int16_t bank = 0; + std::string presetName; // user-facing label / recovery hint + float gain = 0.0f; // 0.0 = use default 1.0x + int8_t transpose = 0; + int8_t reverb = -1; // CC 91; -1 = no override (default 0) + int8_t chorus = -1; // CC 93; -1 = no override (default 0) + int8_t cutoff = -1; // CC 74; -1 = no override (default 64) + int8_t q = -1; // CC 71; -1 = no override (default 64) + bool enabled = false; + bool selected = false; + uint32_t lastEnabledSeq = 0; // process-lifetime; not persisted + EntrySource source = EntrySource::UserPicked; + // ── Note-range split (engine-semitone space) ───────────────────────── + // noteLow/noteHigh bound which engine `semitone` values this entry claims + // (inclusive); default 0..127 is an unsplit entry. fixedNote pins the OUTPUT + // note: -1 derives it from `semitone` (normal melodic path), >= 0 plays that + // exact MIDI note. So the slot index selects WHICH entry wins; fixedNote + // selects WHAT it plays. route dispatches the winner (synth / sample / silent). + uint8_t noteLow = 0; + uint8_t noteHigh = 127; + int16_t fixedNote = -1; + EntryRoute route = EntryRoute::Synth; + // Runtime: resolved sfontId for ProgramSelect. -1 when the pack isn't + // currently loaded OR the SF doesn't have (bank, program). + // Refreshed by AudioEditor's pack-apply path; never persisted. + int16_t sfontId = -1; + // Runtime: forward link of the active-split chain for this pair, sorted by + // resolution priority (source > specificity > pack order), so the audio + // thread's first-covering-entry walk reproduces the per-semitone winner even + // when ranges overlap (-1 = chain tail). Built by RecomputeActive; walked by + // index over the reserved mEntries vector. Acyclic (each entry linked at most + // once). Never serialised. + int16_t nextActiveSplit = -1; +}; + +class MidiTranslator { + public: + static MidiTranslator& Instance(); + + // Audio-thread entry point (see audio_playback.c hook). + bool ProcessNote(int noteIndex, float freqScale, float velocity, uint8_t pan, float channelVolume, uint8_t fontId, + int16_t instOrWave, uint8_t semitone, bool isFinished, uint8_t channelIdx, float resampleRate, + float pitchBend); + void NoteDisabled(int noteIndex); + void Reset(); + + // Number of MIDI channels currently held by synth-routed pairs, out of + // kMaxMidiChannels. If this reaches the cap, new pairs share channel 0 and + // some instruments go silent. Game-thread read of an audio-thread counter, + // intentionally unsynchronised (a stale value is fine for a UI gauge). + uint8_t GetChannelsInUse() const; + // Times the channel pool recycled an idle pair's channel; nonzero means + // reclamation is working (no collapse onto channel 0). Companion gauge to + // GetChannelsInUse() for the FluidSynth tab. + uint32_t GetChannelReclaims() const; + + static constexpr int kMaxNotes = 64; + static constexpr int kMaxFontId = 64; + static constexpr int kMaxInstOrWave = 256; + static constexpr int kMaxDiscovered = 128; + static constexpr uint8_t kMaxMidiChannels = 64; + // mEntries capacity reserved at construction so push_back never + // reallocates within session bounds. The audio thread reads entry + // fields by index without locking; reallocation would invalidate + // those reads. + static constexpr size_t kMaxEntries = 4096; + + // ── Discovery snapshot for the UI ──────────────────────────────────── + int DiscoveredSnapshot(DiscoveredPair* out, int outCap) const; + void ClearDiscovered(); + uint8_t GetSynthActiveCount(uint8_t fontId, int16_t instOrWave) const; + uint8_t GetNativeActiveCount(uint8_t fontId, int16_t instOrWave) const; + // Per-entry (per split row) active counts, for the row activity tint. + // A note attributes to the entry it resolved to, so a single drum slot + // or melodic range lights up independently of the rest of its pair. + uint8_t GetEntrySynthActive(int idx) const; + uint8_t GetEntryNativeActive(int idx) const; + // Aggregate per-entry activity over all of a pair's entries, to tint a SPLIT + // parent row from its children: green if any child is synth-active, blue if any + // is native-active and none synth-active, else uncoloured. Unlike the per-pair + // counts above, this only sees children that attributed to an entry. + void GetPairEntryActivity(uint8_t fontId, int16_t instOrWave, bool& anySynth, bool& anyNative) const; + + // DEBUG: per-pair running stats accumulated by ProcessNote, surfaced in the + // AudioEditor's per-row "[DBG]" popup. Read by the UI without locking; + // best-effort, a torn read is harmless cosmetic. + struct DebugPairStats { + uint32_t noteOns = 0; + uint32_t routedSynth = 0; + uint32_t routedNative = 0; + uint32_t routedMute = 0; // bank == 128 / engine drum-SFX / placeholder + uint8_t lastSemitone = 0; + }; + DebugPairStats GetDebugStats(uint8_t fontId, int16_t instOrWave) const; + void ResetDebugStats(); + void ResetDebugStatsForPair(uint8_t fontId, int16_t instOrWave); + + // Per-slot NoteOn histogram for the engine drum (instOrWave 0) and SFX + // (instOrWave 1) channels, where the `semitone` byte is a slot index + // (Audio_GetDrum / Audio_GetSfx), not a pitch. Fills out[0..127] with per-slot + // counts and returns the number of distinct slots that fired; the discovery + // input for the drum auto-split. Returns 0 for non-drum/SFX pairs. Lock-free + // read of audio-thread atomics. + int GetDrumSlotHistogram(uint8_t fontId, int16_t instOrWave, uint32_t out[128]) const; + + // ── Pack stack + entry sfontId refresh ─────────────────────────────── + // Set by AudioEditor after every SF stack change. Used as the + // multi-enabled tiebreak rank — last entry in this list wins. + void SetPackLoadOrder(const std::vector& order); + + // Refresh each entry's runtime sfontId from a flat list of (pack, sfontId, + // bank, program) tuples. Entries whose pack isn't listed, or whose (bank, + // program) isn't in the matched SF, get sfontId=-1. Cached so PickPreset / + // ClickSynth can resolve sfontIds for new entries without a UI callback. + struct LoadedPresetRef { + int sfontId; + std::string packName; + int bank; + int program; + }; + void RefreshEntrySfontIds(const std::vector& loadedPresets); + + // Drop every ModSupplied entry whose pack is NOT in the supplied set, + // so pack toggles don't leave orphan mod entries hanging with + // sfontId=-1. User-saved entries stay (they're recoverable). + void RemoveModEntriesNotIn(const std::set& packNamesLoaded); + + // Rebuild mActiveEntryIdx for every (fontId, instOrWave). Called after + // batches of mutations (pack load, file load). + void RecomputeAllActive(); + + // ── Entry queries (UI) ─────────────────────────────────────────────── + int GetEntryCount() const { + return static_cast(mEntries.size()); + } + const ConfigEntry& GetEntry(int idx) const { + return mEntries[idx]; + } + const ConfigEntry* GetActiveEntry(uint8_t fontId, int16_t instOrWave) const; + int GetActiveEntryIdx(uint8_t fontId, int16_t instOrWave) const; + void GetEntriesForPair(uint8_t fontId, int16_t instOrWave, std::vector& outIdx) const; + int FindEntry(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, + uint8_t noteLow = 0) const; + int CountSelectedEntries(uint8_t fontId, int16_t instOrWave) const; + + // ── Row UI actions ─────────────────────────────────────────────────── + // Picking a preset from the Preset combo: disable every currently- + // enabled entry for (fontId, instOrWave); if the previous winner is + // from the same pack as the new pick clear its selected; find-or- + // create the picked entry; enable + select + bump lastEnabledSeq. + void PickPreset(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, int16_t bank, + const std::string& presetName); + // Disables every enabled entry for (fontId, instOrWave). selected + // flags are preserved so ClickSynth can restore. + void ClickNative(uint8_t fontId, int16_t instOrWave); + // Restoration order: + // 1. selected entries for (fontId, instOrWave) -> highest seq wins + // 2. fallback: any disabled-but-resolvable entry (sfontId >= 0) -> + // highest seq wins (handles the mod-only-row case where the + // user never picked anything) + // 3. placeholder { packName="", program=-1, presetName="None" } + // (audible result: muted) + void ClickSynth(uint8_t fontId, int16_t instOrWave); + + // Drum auto-split: for each engine slot the drum/SFX channel actually + // fired (from the GetDrumSlotHistogram data), create a single-slot entry + // (noteLow==noteHigh==slot) pinned to a default bank-128 kit (the first + // loaded percussion preset) with a placeholder GM percussion note. The + // user then refines the kit / Drum Sound per slot. Re-running it re-enables + // existing slot entries rather than duplicating them. No-op outside the + // drum/SFX channels. + void AutoSplitDrums(uint8_t fontId, int16_t instOrWave); + + // Manually create a single drum slot at `slot` (engine-semitone index) for a + // drum/SFX or forced-drum pair, without waiting for runtime discovery. Mirrors + // one AutoSplitDrums row: pinned to the pair's kit, Native by default (the user + // picks a Drum Sound to make it synth). Reuses an existing slot entry rather + // than duplicating. Lets a pair be configured offline (e.g. a forced-drum pair + // whose song hasn't played yet). No-op outside drum-like pairs. + void AddDrumSlot(uint8_t fontId, int16_t instOrWave, uint8_t slot); + + // Drum channel Mode (master switch). Sets an explicit per-pair flag, + // separate from per-slot `enabled`, so toggling it never rewrites the + // per-slot state. Native (default) => every slot plays the engine drum + // regardless of per-slot enabled (and the UI grays the slot controls); + // Synth => per-slot enabled decides synth vs native. Switching to Synth + // with no slots yet auto-splits to populate the rows (created Native by + // default). IsDrumChannelSynth reads the flag (audio-thread safe). + void SetDrumChannelSynth(uint8_t fontId, int16_t instOrWave, bool synth); + bool IsDrumChannelSynth(uint8_t fontId, int16_t instOrWave) const; + // "Treat as drum": generalises the drum master from the intrinsic drum/SFX + // channels (instOrWave 0/1) to any melodic pair (>= 2). A flagged pair runs the + // drum resolution path: each distinct incoming semitone becomes a slot mapped to + // a GM percussion sound (fixedNote). Flagging on == Synth mode; clearing returns + // the pair to melodic routing. IsForcedDrum reads the flag (audio-thread safe). + // No-op for instOrWave 0/1 (use SetDrumChannelSynth). + void SetForcedDrum(uint8_t fontId, int16_t instOrWave, bool forced); + bool IsForcedDrum(uint8_t fontId, int16_t instOrWave) const; + // Set the kit (bank-128 pack/program) for every selected slot entry of a + // drum pair at once -- the GM percussion note stays per slot (fixedNote). + void SetDrumKit(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, + const std::string& presetName); + + // ── Note-range split editing (melodic) ─────────────────────────────── + // SplitEntry bisects entry idx's range at `atSemitone`: idx keeps + // [noteLow, atSemitone-1], a copy takes [atSemitone, noteHigh]. Returns the new + // sibling index, or -1 if the cut is out of range / pool full. MergeWithNext + // absorbs the adjacent higher range back into idx. SetEntryNoteRange sets an + // entry's [lo,hi]. AutoSplitByEngineRanges splits a melodic pair's active entry + // into the engine's low/normal/high ranges (boundaries from InstrumentNames). + int SplitEntry(int idx, uint8_t atSemitone); + // Split entry idx into `parts` (>=2) near-equal contiguous ranges in one shot + // (the UI's "split into N"), clamped so no range empties. Returns idx (the + // lowest range). Repeated bisection via SplitEntry, so siblings inherit the + // preset; the user reassigns each range after. + int SplitEntryEven(int idx, int parts); + void MergeWithNext(int idx); + void SetEntryNoteRange(int idx, uint8_t noteLow, uint8_t noteHigh); + // Move the single boundary between two adjacent split ranges atomically: + // lowerIdx keeps [noteLow .. boundary], upperIdx takes [boundary+1 .. noteHigh], + // clamped so neither range empties. One chain recompute, so the audio thread + // never sees a transient overlap/gap. The only way the split UI edits ranges, + // keeping them contiguous and non-overlapping with the outer ends pinned. + void SetSplitBoundary(int lowerIdx, int upperIdx, uint8_t boundary); + void AutoSplitByEngineRanges(uint8_t fontId, int16_t instOrWave); + // Set a specific entry's melodic preset (used by a split range row's preset + // dropdown -- scoped to that range, unlike PickPreset which is per-pair). + // Enables + selects the entry, route=Synth, fixedNote=-1 (pitch from + // semitone). Pass program<0 / empty pack to leave it (use SetEntryEnabled + // false for "None/native"). + void SetEntryPreset(int idx, const std::string& pack, int16_t program, int16_t bank, const std::string& presetName); + + // ── Per-entry mutators (UI Override widgets) ───────────────────────── + void SetEntryGain(int idx, float gain); + void SetEntryTranspose(int idx, int8_t semis); + void SetEntryReverb(int idx, int8_t cc); + void SetEntryChorus(int idx, int8_t cc); + void SetEntryFilterCutoff(int idx, int8_t cc); + void SetEntryFilterResonance(int idx, int8_t cc); + // Split-row mutators. fixedNote pins the output note (-1 = derive from + // semitone); route switches a split between synth / engine-sample / + // silent; enabled drops a split out of resolution (its slot falls back to + // native) without deleting it. route/enabled recompute the pair's chain. + void SetEntryFixedNote(int idx, int16_t note); + void SetEntryRoute(int idx, EntryRoute route); + void SetEntryEnabled(int idx, bool enabled); + + // ── Per-pair display name (row label, sparse) ──────────────────────── + std::string GetDisplayName(uint8_t fontId, int16_t instOrWave) const; + void SetDisplayName(uint8_t fontId, int16_t instOrWave, const std::string& name); + + // ── Session-only transient state ───────────────────────────────────── + bool IsTemporarilyMuted(uint8_t fontId, int16_t instOrWave) const; + void SetTemporaryMute(uint8_t fontId, int16_t instOrWave, bool muted); + void ClearAllTemporaryMutes(); + // Per-split transient mute, keyed by (font, inst, noteLow) so it survives + // entry reordering. Lets a single drum slot / melodic range be isolated. + // Checked after resolution: a muted split silences both paths for notes + // whose covering entry starts at noteLow. + bool IsTemporarilySlotMuted(uint8_t fontId, int16_t instOrWave, uint8_t noteLow) const; + void SetTemporarySlotMute(uint8_t fontId, int16_t instOrWave, uint8_t noteLow, bool muted); + void ClearAllTemporarySlotMutes(); + float GetTemporaryVolume(uint8_t fontId, int16_t instOrWave) const; + void SetTemporaryVolume(uint8_t fontId, int16_t instOrWave, float vol); + void ClearAllTemporaryVolumes(); + + // ── Persistence + reset ────────────────────────────────────────────── + void ResetAllOverrides(); + bool SaveOverridesToFile(const std::string& path) const; + // String overlay = pack mapping.json: new entries land with source=ModSupplied, + // selected=false, enabled=true. `packName`, when non-empty, is the pack's + // discovered name and the authoritative owner for every entry, so mapping.json + // needn't repeat a per-entry "pack" field. When empty, the owner falls back to + // the per-entry "pack", then the file's top-level "pack_name". + bool ApplyOverridesFromString(const std::string& json, const std::string& packName = ""); + // File overlay = user fluidsynth_overrides.json: entries land with + // source=UserPicked. enabled / selected come from the file; missing + // fields default to true. + bool ApplyOverridesFromFile(const std::string& path); + + // Publish-as-pack-mapping. Writes a mapping.json shaped for distribution + // inside a synth pack: only enabled+selected entries belonging to + // `packNameFilter` are emitted, runtime fields are stripped, and the + // file carries a top-level "pack_name" field. Returns the number of + // entries written (or -1 on I/O failure). When packNameFilter is empty, + // every selected entry is emitted regardless of its source pack. + int ExportPackMapping(const std::string& path, const std::string& packNameFilter) const; + + // Same content ExportPackMapping writes, built in memory (no file I/O). + // `outWritten` receives the entry count. Used by the one-click .o2r + // bundler so the JSON and the soundfont go straight into the archive. + std::string BuildPackMappingJson(const std::string& packNameFilter, int& outWritten) const; + + // Helper: how many entries `ExportPackMapping` would emit for a given + // pack filter. Same predicate as the writer; lets the UI show a preview + // count before the user clicks Export. + int CountExportableEntries(const std::string& packNameFilter) const; + + void SetSynthMode(SynthMode mode) { + mSynthMode = mode; + } + SynthMode GetSynthMode() const { + return mSynthMode; + } + + // Effective global synth gain, configured upfront by the AudioEditor. + void SetGlobalGain(float gain) { + mGlobalGain = gain; + } + + private: + MidiTranslator(); + + void RecordDiscovery(uint8_t fontId, int16_t instOrWave, bool mapped); + // Balanced inc/dec of the per-entry active counters from ProcessNote's + // activation paths and retire/NoteDisabled. idx<0 (native fall-through) is + // a no-op so error/fallback paths can pass -1 freely. + void IncEntryActive(bool synth, int idx); + void DecEntryActive(bool synth, int idx); + // Current kit (pack/program) for a drum pair: an existing selected bank-128 + // slot entry if any, else the first loaded bank-128 preset, else empty. + void ResolveDrumKit(uint8_t fontId, int16_t instOrWave, std::string& outPack, int& outProgram) const; + // Index of a selected entry whose range starts at `slot` (its noteLow), + // enabled or not. Lets a native-routed drum note attribute its activity to + // the slot's row even when the entry is disabled. -1 if none. + int FindSlotEntryIdx(uint8_t fontId, int16_t instOrWave, uint8_t slot) const; + // Per-slot NoteOn histogram backing this pair's slot discovery, or nullptr if + // the pair has none (not a drum/SFX channel and not a forced-drum pair). + // Centralises the "drum/SFX use the static grid, forced pairs use a pool + // slot" choice so every reader/writer agrees. Points at [0..127]. + struct DrumSlotHit; + DrumSlotHit* DrumHistFor(uint8_t fontId, int16_t instOrWave); + const DrumSlotHit* DrumHistFor(uint8_t fontId, int16_t instOrWave) const; + // Claim a free forced-drum histogram pool slot, zeroing it; -1 if the pool + // is full (>= kMaxForcedDrumPairs flagged at once). + int AllocForcedDrumPool(); + uint8_t AllocateChannelForPair(uint8_t fontId, int16_t instOrWave); + // Frees the channel of the first idle pair found (no active synth notes, + // and not the requesting pair) and returns it, or 0xFF if every channel + // is currently sounding. Caller assigns ownership of the returned slot. + uint8_t ReclaimIdleChannel(uint8_t exceptFontId, int16_t exceptInst); + + // Find an entry by primary key; create a new one if not present. + // Caller fills bank/presetName even when creating, and decides + // source / selected / enabled after. Returns the entry index. + int FindOrCreateEntry(uint8_t fontId, int16_t instOrWave, const std::string& pack, int16_t program, int16_t bank, + const std::string& presetName, EntrySource source, uint8_t noteLow = 0); + + // Recompute mActiveEntryIdx[fontId][instOrWave]. Resolution: filter + // mEntries by (fontId, instOrWave) && enabled && sfontId>=0; pick the + // candidate whose pack ranks highest in mPackLoadOrder (last loaded + // wins). Placeholder entries (program<0) participate normally — they + // resolve only when explicitly enabled and produce muted output. + void RecomputeActive(uint8_t fontId, int16_t instOrWave); + int PackRank(const std::string& pack) const; // -1 if not in load order + + // Walks the cached loaded-preset list and returns the sfontId for + // (pack, bank, program), or -1 if the pack isn't loaded / the SF + // doesn't have that preset. Used at entry-create time so a fresh + // pick is immediately resolvable without waiting for the next + // pack-stack change. + int16_t ResolveSfontIdFromCache(const std::string& pack, int16_t bank, int16_t program) const; + + NoteTranslatorState mNoteState[kMaxNotes] = {}; + ChannelState mChannelState[kMaxMidiChannels] = {}; + + uint8_t mSynthActiveByPair[kMaxFontId][kMaxInstOrWave] = {}; + uint8_t mNativeActiveByPair[kMaxFontId][kMaxInstOrWave] = {}; + + // Per-entry active counts (indexed by entry index), for the per-split-row + // activity tint. Written by the audio thread on note activation/retire, + // read lock-free by the UI. Index shifts on entry erase leave these + // briefly stale until notes replay -- cosmetic only. + uint8_t mEntrySynthActive[kMaxEntries] = {}; + uint8_t mEntryNativeActive[kMaxEntries] = {}; + + uint8_t mPairChannel[kMaxFontId][kMaxInstOrWave]; + uint8_t mChannelsAllocated = 0; + + // Reverse map for the reclaim-on-exhaustion path: which pair owns each + // allocated MIDI channel. instOrWave == -1 marks an unowned slot. Lets + // ReclaimIdleChannel scan 64 channels instead of the full pair grid. + struct ChannelOwner { + uint8_t fontId = 0; + int16_t instOrWave = -1; // -1 = unowned + }; + ChannelOwner mChannelOwner[kMaxMidiChannels] = {}; + // Count of times an idle pair's channel was reclaimed for a new pair. Pure + // diagnostic: nonzero means the pool wrapped and recycling kicked in. + uint32_t mChannelReclaims = 0; + + std::array mSeenBits{}; + DiscoveredPair mDiscovered[kMaxDiscovered] = {}; + std::atomic mDiscoveredCount{ 0 }; + + std::set> mTemporaryMute; + std::set> mTemporarySlotMute; + std::map, float> mTemporaryVolume; + std::map, std::string> mDisplayName; + + // DEBUG: per-pair stats backing storage. Counters use relaxed atomics; read + // from the UI without synchronisation (best-effort diagnostic). + struct DebugSlot { + std::atomic noteOns{ 0 }; + std::atomic routedSynth{ 0 }; + std::atomic routedNative{ 0 }; + std::atomic routedMute{ 0 }; + uint8_t lastSemitone = 0; + }; + DebugSlot mDebugStats[kMaxFontId][kMaxInstOrWave]; + + // Per-slot NoteOn counts for the drum (instOrWave 0) and SFX (1) channels only, + // scoped to 2 instOrWaves to keep the table small. Written by the audio thread + // on every fresh NoteOn; read lock-free by the UI / GetDrumSlotHistogram. Index + // is [fontId][instOrWave (0|1)][semitone 0..127]. + static constexpr int kDrumHistInst = 2; // instOrWave 0 (drum) + 1 (SFX) + static constexpr int kDrumHistSlots = 128; + struct DrumSlotHit { + std::atomic count{ 0 }; + }; + DrumSlotHit mDrumSlotHits[kMaxFontId][kDrumHistInst][kDrumHistSlots]; + + // Per-pair drum channel mode (the per-instrument Native/Synth master), separate + // from per-slot `enabled` so setting a slot Native doesn't flip the instrument. + // Native (false, default) => all slots play the engine drum; Synth (true) => + // per-slot enabled decides. Audio thread reads per note, UI writes; torn bool + // read is benign. Index [font][0|1]. + bool mDrumChannelSynth[kMaxFontId][kDrumHistInst] = {}; + + // ── Forced-drum ("Treat as drum") pairs ────────────────────────────── + // A flagged melodic pair routes through the drum path. The grid doubles as the + // flag AND the histogram-pool index: -1 = not forced; 0..kMaxForcedDrumPairs-1 = + // forced, value is the pool slot recording the pair's incoming notes. Audio + // thread reads per note, UI writes (torn int8 read benign). Only instOrWave >= + // kDrumHistInst is flagged. Initialised to -1 in the constructor. + static constexpr int kMaxForcedDrumPairs = 32; + int8_t mForcedDrumPool[kMaxFontId][kMaxInstOrWave]; + bool mForcedDrumPoolUsed[kMaxForcedDrumPairs] = {}; + DrumSlotHit mForcedDrumHits[kMaxForcedDrumPairs][kDrumHistSlots]; + + // ── Entry-based config storage ──────────────────────────────────────── + std::vector mEntries; + // -1 = no active entry for this pair; native plays. + int16_t mActiveEntryIdx[kMaxFontId][kMaxInstOrWave]; + uint32_t mNextSeq = 1; + std::vector mPackLoadOrder; + // Cached copy of the AudioEditor's sLoadedPresets list. Refreshed by + // RefreshEntrySfontIds; consumed by ResolveSfontIdFromCache so any + // mutator (PickPreset, ClickSynth) can set a new entry's sfontId + // immediately without going through another pack-stack pass. + std::vector mLoadedPresets; + + SynthMode mSynthMode = SynthMode::Authentic; + // Set by the AudioEditor; default is a safe fallback until the first apply. + float mGlobalGain = 0.70f; +}; + +} // namespace SOH diff --git a/soh/soh/OTRGlobals.cpp b/soh/soh/OTRGlobals.cpp index 8e1e5148ff3..3db21de7e0f 100644 --- a/soh/soh/OTRGlobals.cpp +++ b/soh/soh/OTRGlobals.cpp @@ -29,6 +29,8 @@ #include #include #include +#include "soh/Enhancements/audio/MidiSynthManager.h" +#include "soh/Enhancements/audio/AudioResampler.h" #include "Enhancements/speechsynthesizer/SpeechSynthesizer.h" #include "Enhancements/controls/SohInputEditorWindow.h" #include "Enhancements/audio/AudioCollection.h" @@ -38,6 +40,7 @@ #include "Enhancements/randomizer/randomizer_check_tracker.h" #include "Enhancements/randomizer/static_data.h" #include "soh/Enhancements/randomizer/settings.h" +#include "Enhancements/gameplaystats.h" #include "soh/Enhancements/savestates.h" #include "frame_interpolation.h" #include "SohGui/SohMenu.h" @@ -77,6 +80,7 @@ #include #include "Enhancements/item-tables/ItemTableManager.h" #include "Enhancements/Lang/Lang.h" +#include "soh/SohGui/SohGui.hpp" #include "soh/SohGui/ImGuiUtils.h" #include "ActorDB.h" #include "SaveManager.h" @@ -826,11 +830,16 @@ void OTRGlobals::Initialize() { CVarGetInteger(CVAR_SETTING("AutoCaptureMouse"), 1)); context->GetWindow()->SetForceCursorVisibility(CVarGetInteger(CVAR_SETTING("CursorVisibility"), 0)); - context->InitAudio({ .SampleRate = 32000, - .SampleLength = 1024, - // 4096 frames at 32 kHz (~128 ms) gives enough reservoir for frame - // jitter and slow-frame spikes without perceptible audio latency. - .DesiredBuffered = 4096 }); + // Output rate is user-selectable (restart-applied); native synth is 32 kHz and + // the audio thread resamples up to it. Default 32 kHz matches the console. + const int audioOutputRate = CVarGetInteger(CVAR_AUDIO("OutputSampleRate"), 32000); + // ~128 ms reservoir scaled to the rate, capped below the LUS SDL backend's hard + // limit (DoPlay drops past ~6000 frames, which would spin the fill loop). + int desiredBuffered = 4096 * audioOutputRate / 32000; + if (desiredBuffered > 5500) { + desiredBuffered = 5500; + } + context->InitAudio({ .SampleRate = audioOutputRate, .SampleLength = 1024, .DesiredBuffered = desiredBuffered }); // The menu is set up before audio is initialized, so its list of available audio backends has to be // populated here rather than in Menu::InitElement (where the window backends are handled). @@ -1021,24 +1030,88 @@ std::unordered_map ExtensionCache; void OTRAudio_Thread() { #define SAMPLES_HIGH 560 #define SAMPLES_LOW 528 -#define AUDIO_FRAMES_PER_UPDATE (R_UPDATE_RATE > 0 ? R_UPDATE_RATE : 1) #define NUM_AUDIO_CHANNELS 2 - // Single producer routine used by both the wake-driven and pre-buffer - // loops. Captures the per-iteration sample count from the caller. + constexpr int kSourceRate = 32000; + const int outRate = CVarGetInteger(CVAR_AUDIO("OutputSampleRate"), 32000); + + // Null at 32 kHz so the native path stays byte-identical to stock; otherwise a + // stateful stereo resampler lifts the native bus to the device rate. + std::unique_ptr resampler; + if (outRate != kSourceRate) { + resampler = std::make_unique(kSourceRate, outRate, NUM_AUDIO_CHANNELS); + } + + auto toDeviceFrames = [&](u32 nativeFrames) -> int { return (int)((int64_t)nativeFrames * outRate / kSourceRate); }; + + // One engine update per call, not the per-gfx-frame batch: at high rates a + // batched burst nears the backend's ~6000-frame queue cap and starves margin. auto produce_and_play = [&](u32 num_audio_samples) { - const u32 total_frames = num_audio_samples * AUDIO_FRAMES_PER_UPDATE; + const u32 total_frames = num_audio_samples; const u32 total_samples = total_frames * NUM_AUDIO_CHANNELS; - // 3 is the maximum authentic frame divisor. - static thread_local s16 audio_buffer[SAMPLES_HIGH * NUM_AUDIO_CHANNELS * 3]; + static thread_local s16 native_s16[SAMPLES_HIGH * NUM_AUDIO_CHANNELS]; + + AudioMgr_CreateNextAudioBuffer(native_s16, num_audio_samples); + +#if ENABLE_FLUIDSYNTH + auto synth = SOH::MidiSynthManager::Instance().GetActiveSynth(); + const bool haveSynth = (bool)synth; +#else + const bool haveSynth = false; +#endif + + // Stock path: at the native rate with no synth, hand s16 straight through. + if (!resampler && !haveSynth) { + AudioPlayer_Play(reinterpret_cast(native_s16), total_samples * sizeof(int16_t)); + return; + } + + // Worst case: SAMPLES_HIGH frames at the 32k->96k (x3) ratio, stereo, plus + // slack for the resampler's phase rounding. 16384 leaves ample headroom. + static constexpr size_t kMaxSamples = 16384; + static thread_local float native_f32[kMaxSamples]; + static thread_local float synth_f32[kMaxSamples]; + static thread_local float mix_f32[kMaxSamples]; + static thread_local s16 out_s16[kMaxSamples]; + + for (u32 s = 0; s < total_samples; s++) { + native_f32[s] = native_s16[s] * (1.0f / 32768.0f); + } - for (int i = 0; i < AUDIO_FRAMES_PER_UPDATE; i++) { - AudioMgr_CreateNextAudioBuffer(audio_buffer + i * (num_audio_samples * NUM_AUDIO_CHANNELS), - num_audio_samples); + const float* stereo = native_f32; + int outFrames = (int)total_frames; + if (resampler) { + outFrames = resampler->Process(native_f32, (int)total_frames, mix_f32, + resampler->MaxOutputFrames((int)total_frames)); + stereo = mix_f32; } - AudioPlayer_Play(reinterpret_cast(audio_buffer), total_samples * sizeof(int16_t)); +#if ENABLE_FLUIDSYNTH + if (haveSynth) { + synth->Render(synth_f32, (uint32_t)outFrames); + // tanh-style soft clip on the summed bus keeps peaks well-behaved when + // the synth contributes alongside native SFX. + auto softClip = [](float x) { + const float x2 = x * x; + return x * (27.0f + x2) / (27.0f + 9.0f * x2); + }; + const int n = outFrames * NUM_AUDIO_CHANNELS; + for (int i = 0; i < n; i++) { + mix_f32[i] = softClip(stereo[i] + synth_f32[i]); + } + stereo = mix_f32; + } +#endif + + // Stereo s16 out; libultraship still does channel layout / 5.1 downstream. + const int n = outFrames * NUM_AUDIO_CHANNELS; + for (int i = 0; i < n; i++) { + float v = stereo[i] * 32767.0f; + v = v > 32767.0f ? 32767.0f : (v < -32768.0f ? -32768.0f : v); + out_s16[i] = (s16)lrintf(v); + } + AudioPlayer_Play(reinterpret_cast(out_s16), (size_t)n * sizeof(int16_t)); }; // Self-pump cadence. The gfx thread wakes us once per rendered frame @@ -1085,7 +1158,7 @@ void OTRAudio_Thread() { // Generating PCM that DoPlay() would refuse creates a discontinuity // audible as a click. The pre-buffer loop below will catch up once // the backend drains enough. - if (AudioPlayer_Buffered() + SAMPLES_LOW * AUDIO_FRAMES_PER_UPDATE > AudioPlayer_GetDesiredBuffered()) { + if (AudioPlayer_Buffered() + toDeviceFrames(SAMPLES_LOW) > AudioPlayer_GetDesiredBuffered()) { audio.processing = false; } else { produce_and_play(num_audio_samples); @@ -1099,12 +1172,18 @@ void OTRAudio_Thread() { // The producer guard (same as above) prevents advancing the audio engine // when the backend ring is already at capacity. while (audio.running && AudioPlayer_Buffered() < AudioPlayer_GetDesiredBuffered()) { - if (AudioPlayer_Buffered() + SAMPLES_LOW * AUDIO_FRAMES_PER_UPDATE > AudioPlayer_GetDesiredBuffered()) { + if (AudioPlayer_Buffered() + toDeviceFrames(SAMPLES_LOW) > AudioPlayer_GetDesiredBuffered()) { break; } int samples_left = AudioPlayer_Buffered(); u32 num_audio_samples = samples_left < AudioPlayer_GetDesiredBuffered() ? SAMPLES_HIGH : SAMPLES_LOW; produce_and_play(num_audio_samples); + + // If the backend didn't retain what we produced (full queue or a + // discarding sink), stop -- else we race the sequencer against silence. + if (AudioPlayer_Buffered() <= samples_left) { + break; + } } } } diff --git a/soh/soh/SohGui/SohMenuSettings.cpp b/soh/soh/SohGui/SohMenuSettings.cpp index 64316772697..3a5efb82a64 100644 --- a/soh/soh/SohGui/SohMenuSettings.cpp +++ b/soh/soh/SohGui/SohMenuSettings.cpp @@ -5,6 +5,7 @@ #include "soh/OTRGlobals.h" #include #include "soh/ResourceManagerHelpers.h" +#include "soh/Enhancements/audio/AudioEditor.h" #include "UIWidgets.hpp" #include @@ -290,7 +291,12 @@ void SohMenu::AddMenuSettings() { AddWidget(path, "Master Volume: %d %%", WIDGET_CVAR_SLIDER_INT) .CVar(CVAR_SETTING("Volume.Master")) .RaceDisable(false) - .Options(IntSliderOptions().Min(0).Max(100).DefaultValue(40).ShowButtons(true).Format("")); + .Options(IntSliderOptions().Min(0).Max(100).DefaultValue(40).ShowButtons(true).Format("")) + .Callback([](WidgetInfo& info) { + // Master scales the native engine per-note on its own; mirror it onto + // the FluidSynth master gain so the synth tracks the slider too. + AudioEditor_ApplySynthMasterVolume(); + }); AddWidget(path, "Main Music Volume: %d %%", WIDGET_CVAR_SLIDER_INT) .CVar(CVAR_SETTING("Volume.MainMusic")) .RaceDisable(false) @@ -323,6 +329,21 @@ void SohMenu::AddMenuSettings() { Audio_SetGameVolume(SEQ_PLAYER_SFX, ((float)CVarGetInteger(CVAR_SETTING("Volume.SFX"), 100) / 100.0f)); }); AddWidget(path, "Audio API (Needs reload)", WIDGET_AUDIO_BACKEND).RaceDisable(false); + AddWidget(path, "Output Sample Rate (Restart required)", WIDGET_CVAR_COMBOBOX) + .CVar(CVAR_AUDIO("OutputSampleRate")) + .RaceDisable(false) + .Options(ComboboxOptions() + .ComboMap({ { 32000, "32000 Hz (native)" }, + { 44100, "44100 Hz" }, + { 48000, "48000 Hz" }, + { 96000, "96000 Hz" } }) + .Tooltip("Output device sample rate. The native engine renders at 32 kHz and is " + "resampled up to this rate, which any SoundFont synthesis also targets. " + "48000 suits modern PCs; pick 44100 on hardware that only supports it. " + "32000 (native) skips resampling entirely for the lowest overhead and the " + "most authentic output, at the cost of synth high-frequency headroom. " + "Takes effect after restarting the game.") + .DefaultIndex(32000)); // Graphics Settings static int32_t maxFps = 360; diff --git a/soh/soh/mixer.c b/soh/soh/mixer.c index df620013994..338d6582269 100644 --- a/soh/soh/mixer.c +++ b/soh/soh/mixer.c @@ -1,7 +1,10 @@ //! This file is always optimized by a rule in the CMakeList. This is done because the SIMD functions are very large //! when unoptimized and clang does not allow optimizing a single function. +#include #include #include +#include +#include #include "mixer.h" #ifndef __clang__ @@ -89,6 +92,15 @@ static inline int32_t clamp32(int64_t v) { return (int32_t)v; } +/* Saturate a float accumulator to s16 range, rounding once at conversion. + * Used by the float mixing paths to avoid intermediate s16 truncation. */ +static inline int16_t clamp(double v) { + if (v > INT16_MAX) + return INT16_MAX; + if (v < INT16_MIN) + return INT16_MIN; + return (int16_t)lrint(v); +} void aClearBufferImpl(uint16_t addr, int nbytes) { nbytes = ROUND_UP_16(nbytes); memset(BUF_U8(addr), 0, nbytes); @@ -240,7 +252,6 @@ void aResampleImpl(uint8_t flags, uint16_t pitch, RESAMPLE_STATE state) { uint32_t pitch_accumulator; int i; int16_t* tbl; - int32_t sample; if (flags & A_INIT) { memset(tmp, 0, 5 * sizeof(int16_t)); @@ -255,12 +266,15 @@ void aResampleImpl(uint8_t flags, uint16_t pitch, RESAMPLE_STATE state) { pitch_accumulator = (uint16_t)tmp[4]; memcpy(in, tmp, 4 * sizeof(int16_t)); + /* Accumulate all four FIR taps in double precision and round once in clamp(), + * instead of rounding each tap, cutting quantization noise on low-volume + * signals and slow pitch changes. */ do { for (i = 0; i < 8; i++) { tbl = resample_table[pitch_accumulator * 64 >> 16]; - sample = ((in[0] * tbl[0] + 0x4000) >> 15) + ((in[1] * tbl[1] + 0x4000) >> 15) + - ((in[2] * tbl[2] + 0x4000) >> 15) + ((in[3] * tbl[3] + 0x4000) >> 15); - *out++ = clamp16(sample); + double sample = (double)in[0] * (double)tbl[0] + (double)in[1] * (double)tbl[1] + + (double)in[2] * (double)tbl[2] + (double)in[3] * (double)tbl[3]; + *out++ = clamp(sample / 32768.0); pitch_accumulator += (pitch << 1); in += pitch_accumulator >> 16; @@ -294,29 +308,39 @@ void aEnvSetup2Impl(uint16_t initial_vol_left, uint16_t initial_vol_right) { void aEnvMixerImpl(uint16_t in_addr, uint16_t n_samples, bool swap_reverb, bool neg_3, bool neg_2, bool neg_left, bool neg_right, int32_t wet_dry_addr, u32 unk) { - int16_t* in = BUF_S16(in_addr); + const int16_t* in = BUF_S16(in_addr); int16_t* dry[2] = { BUF_S16(((wet_dry_addr >> 24) & 0xFF) << 4), BUF_S16(((wet_dry_addr >> 16) & 0xFF) << 4) }; int16_t* wet[2] = { BUF_S16(((wet_dry_addr >> 8) & 0xFF) << 4), BUF_S16(((wet_dry_addr)&0xFF) << 4) }; - int16_t negs[4] = { neg_left ? -1 : 0, neg_right ? -1 : 0, neg_3 ? -4 : 0, neg_2 ? -2 : 0 }; - int swapped[2] = { swap_reverb ? 1 : 0, swap_reverb ? 0 : 1 }; + const int negs[4] = { neg_left ? -1 : 1, neg_right ? -1 : 1, neg_3 ? -1 : 1, neg_2 ? -1 : 1 }; + const int swapped[2] = { swap_reverb ? 1 : 0, swap_reverb ? 0 : 1 }; int n = ROUND_UP_16(n_samples); uint16_t vols[2] = { rspa.vol[0], rspa.vol[1] }; - uint16_t rates[2] = { rspa.rate[0], rspa.rate[1] }; + const uint16_t rates[2] = { rspa.rate[0], rspa.rate[1] }; uint16_t vol_wet = rspa.vol_wet; - uint16_t rate_wet = rspa.rate_wet; + const uint16_t rate_wet = rspa.rate_wet; + + /* Keep envelope scaling and wet/dry mixing in double precision until the final + * clamp(), preserving fractional precision across volume ramps and reducing + * cumulative quantization noise during multi-voice mixing. */ + const double kScale = 1.0 / 65536.0; do { + const double fvols[2] = { vols[0] * negs[0] * kScale, vols[1] * negs[1] * kScale }; + const double fvol_wet[2] = { vol_wet * negs[2] * kScale, vol_wet * negs[3] * kScale }; + for (int i = 0; i < 8; i++) { - int16_t samples[2] = { *in, *in }; + const double samples[2] = { + *in * fvols[0], + *in * fvols[1], + }; in++; + for (int j = 0; j < 2; j++) { - samples[j] = (samples[j] * vols[j] >> 16) ^ negs[j]; - } - for (int j = 0; j < 2; j++) { - *dry[j] = clamp16(*dry[j] + samples[j]); + *dry[j] = clamp(*dry[j] + samples[j]); dry[j]++; - *wet[j] = clamp16(*wet[j] + ((samples[swapped[j]] * vol_wet >> 16) ^ negs[2 + j])); + + *wet[j] = clamp(*wet[j] + (samples[swapped[j]] * fvol_wet[j])); wet[j]++; } } diff --git a/soh/soh/resource/importer/AudioSoundFontFactory.cpp b/soh/soh/resource/importer/AudioSoundFontFactory.cpp index 4ef73e06a3a..5987ed6262f 100644 --- a/soh/soh/resource/importer/AudioSoundFontFactory.cpp +++ b/soh/soh/resource/importer/AudioSoundFontFactory.cpp @@ -1,11 +1,13 @@ #include "soh/resource/importer/AudioSoundFontFactory.h" #include "soh/resource/type/AudioSoundFont.h" +#include "soh/Enhancements/audio/InstrumentNames.h" #include #include #include "z64audio.h" #include #include #include +#include namespace SOH { std::shared_ptr @@ -105,6 +107,26 @@ ResourceFactoryBinaryAudioSoundFontV2::ReadResource(std::shared_ptr instrument->envelope[j].arg = BE16SWAP(arg); } + // Capture all three range sample filenames so the bypass UI can + // show the actual per-slot instrument label. The InstrumentNames + // registry stores all three; the UI prefers normal, falling back + // to low or high when an instrument has gaps in its pitch ranges. + // The verbose log dump that follows is gated on TRACE; flip the + // spdlog level to debug the empty-name case if music slots come + // up blank after a fresh .o2r regen. + const uint8_t fntIdx = static_cast(audioSoundFont->soundFont.fntIndex); + // Key the registry by instOrWave (array index + 2), not the raw array + // index, so it matches how MidiTranslator and the bypass UI look names + // up. instOrWave 0/1 are the drum/SFX channels; melodic instruments + // start at 2 (see kMelodicInstOrWaveBase and AudioSeq_SetInstrument). + const int16_t slot = static_cast(i) + SOH::kMelodicInstOrWaveBase; + std::string lowName, normalName, highName; + + // Capture the engine's low/normal/high split boundaries so the bypass + // UI can auto-split a melodic pair along the same lines the engine + // routes per-pitch samples. + SOH::SetInstrumentRange(fntIdx, slot, instrument->normalRangeLo, instrument->normalRangeHi); + bool hasLowNoteSoundFontEntry = reader->ReadInt8(); if (hasLowNoteSoundFontEntry) { bool hasSampleRef = reader->ReadInt8(); @@ -113,6 +135,9 @@ ResourceFactoryBinaryAudioSoundFontV2::ReadResource(std::shared_ptr auto res = Ship::Context::GetRawInstance()->GetResourceManager()->LoadResourceProcess(sampleFileName.c_str()); instrument->lowNotesSound.sample = static_cast(res ? res->GetRawPointer() : nullptr); + lowName = sampleFileName; + SOH::SetInstrumentSampleName(fntIdx, slot, SOH::SampleRange::Low, std::move(sampleFileName)); + SOH::SetInstrumentTuning(fntIdx, slot, SOH::SampleRange::Low, instrument->lowNotesSound.tuning); } else { instrument->lowNotesSound.sample = nullptr; instrument->lowNotesSound.tuning = 0; @@ -127,6 +152,9 @@ ResourceFactoryBinaryAudioSoundFontV2::ReadResource(std::shared_ptr auto res = Ship::Context::GetRawInstance()->GetResourceManager()->LoadResourceProcess(sampleFileName.c_str()); instrument->normalNotesSound.sample = static_cast(res ? res->GetRawPointer() : nullptr); + normalName = sampleFileName; + SOH::SetInstrumentSampleName(fntIdx, slot, SOH::SampleRange::Normal, std::move(sampleFileName)); + SOH::SetInstrumentTuning(fntIdx, slot, SOH::SampleRange::Normal, instrument->normalNotesSound.tuning); } else { instrument->normalNotesSound.sample = nullptr; instrument->normalNotesSound.tuning = 0; @@ -140,11 +168,21 @@ ResourceFactoryBinaryAudioSoundFontV2::ReadResource(std::shared_ptr auto res = Ship::Context::GetRawInstance()->GetResourceManager()->LoadResourceProcess(sampleFileName.c_str()); instrument->highNotesSound.sample = static_cast(res ? res->GetRawPointer() : nullptr); + highName = sampleFileName; + SOH::SetInstrumentSampleName(fntIdx, slot, SOH::SampleRange::High, std::move(sampleFileName)); + SOH::SetInstrumentTuning(fntIdx, slot, SOH::SampleRange::High, instrument->highNotesSound.tuning); } else { instrument->highNotesSound.sample = nullptr; instrument->highNotesSound.tuning = 0; } + SPDLOG_DEBUG("[SoundFont] font {} bank1={} bank2={} slot {:3}: low='{}'({:.3f}) normal='{}'({:.3f}) " + "high='{}'({:.3f})", + fntIdx, audioSoundFont->soundFont.sampleBankId1, audioSoundFont->soundFont.sampleBankId2, i, + lowName.empty() ? "" : lowName.c_str(), instrument->lowNotesSound.tuning, + normalName.empty() ? "" : normalName.c_str(), instrument->normalNotesSound.tuning, + highName.empty() ? "" : highName.c_str(), instrument->highNotesSound.tuning); + if (isValidEntry) { audioSoundFont->instrumentAddresses.push_back(instrument); } else { diff --git a/soh/src/code/audio_playback.c b/soh/src/code/audio_playback.c index ce6c5d6f19b..8395db6342f 100644 --- a/soh/src/code/audio_playback.c +++ b/soh/src/code/audio_playback.c @@ -3,6 +3,13 @@ extern bool gUseLegacySD; +#if ENABLE_FLUIDSYNTH +extern bool SOH_MidiTranslator_ProcessNote(int noteIndex, float freqScale, float velocity, uint8_t pan, + float channelVolume, uint8_t fontId, int16_t instOrWave, uint8_t semitone, + bool isFinished, uint8_t channelIdx, float resampleRate, float pitchBend); +extern void SOH_MidiTranslator_NoteDisabled(int noteIndex); +#endif + void Audio_InitNoteSub(Note* note, NoteSubEu* sub, NoteSubAttributes* attrs) { f32 volRight, volLeft; s32 smallPanIndex; @@ -167,6 +174,10 @@ void Audio_NoteDisable(Note* note) { aOPUSFree(note->synthesisState.opusFile); note->synthesisState.opusFile = NULL; } + +#if ENABLE_FLUIDSYNTH + SOH_MidiTranslator_NoteDisabled((int)(note - gAudioContext.notes)); +#endif } void Audio_ProcessNotes(void) { @@ -305,6 +316,60 @@ void Audio_ProcessNotes(void) { subAttrs.velocity *= scale; Audio_InitNoteSub(note, noteSubEu2, &subAttrs); + +#if ENABLE_FLUIDSYNTH + { + SequenceLayer* layer = playbackState->parentLayer; + int16_t instOrWave = 0; + uint8_t semitone = 0; + uint8_t chanIdx = 0; + float channelVolume = 1.0f; + if (layer != NO_LAYER && layer->channel != NULL) { + instOrWave = (layer->instOrWave == (int16_t)0xFF) ? layer->channel->instOrWave : layer->instOrWave; + semitone = layer->semitone; + channelVolume = layer->channel->volume; + SequenceChannel** channels = layer->channel->seqPlayer->channels; + + size_t channelCount = + sizeof(layer->channel->seqPlayer->channels) / sizeof(layer->channel->seqPlayer->channels[0]); + + for (size_t i = 0; i < channelCount; ++i) { + if (channels[i] == layer->channel) { + chanIdx = (uint8_t)i; + break; + } + } + } + // FluidSynth runs its own volume envelope, so pass the raw key-strike + // velocity, not subAttrs.velocity (already ADSR-scaled and near-zero + // during attack). + float noteVelocity = 1.0f; + if (layer != NO_LAYER) { + noteVelocity = layer->noteVelocity; + } + // Vibrato and portamento ride on the resampling ratio, not on separate + // fields, so derive the pitch-bend wheel value from the final frequency. + // Dividing by the nominal note frequency cancels sample tuning and the + // resample rate, leaving only the continuous pitch modulation. + float pitchBend = 1.0f; + if (layer != NO_LAYER && layer->sound != NULL && semitone < 0x80 && subAttrs.frequency > 0.0f) { + f32 nominal = gNoteFrequencies[semitone] * layer->sound->tuning * resampRate; + if (nominal > 0.0f) { + pitchBend = subAttrs.frequency / nominal; + } + } + bool handledByFluidSynth = SOH_MidiTranslator_ProcessNote( + i, subAttrs.frequency, noteVelocity, subAttrs.pan, channelVolume, playbackState->fontId, instOrWave, + semitone, (bool)noteSubEu->bitField0.finished, chanIdx, + gAudioContext.audioBufferParameters.resampleRate, pitchBend); + if (handledByFluidSynth) { + // FluidSynth owns this note; silence the native side so the two + // synthesis paths don't sum into a doubled signal. + noteSubEu->bitField0.enabled = false; + } + } +#endif + noteSubEu->bitField1.bookOffset = bookOffset; skip:; }